@plitzi/sdk-event-bridge 0.37.2 → 0.37.3
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 +1149 -0
- package/dist/EventBridge.d.ts +11 -1
- package/dist/EventBridge.mjs +13 -10
- package/dist/hooks/useEventBridge.mjs +12 -12
- package/package.json +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,1154 @@
|
|
|
1
1
|
# @plitzi/sdk-event-bridge
|
|
2
2
|
|
|
3
|
+
## 0.37.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- cadd1b9: ## Authoring: nothing renders wrong in silence
|
|
8
|
+
|
|
9
|
+
- `authorSpace` reads every template with the runtime's own parser and refuses what it would read past: an operator it
|
|
10
|
+
does not implement (`matches`), an unknown filter, function or tag, a stray character, an unclosed bracket.
|
|
11
|
+
- Binding templates and attribute tokens are held to what is in scope: a name nothing answers to, a short source name,
|
|
12
|
+
or a source read from outside the element that publishes it is refused with the name it should have been.
|
|
13
|
+
- Children on a type that holds none (`heading`, `text`, `paragraph`, `image`, `formControl`…) are refused.
|
|
14
|
+
- A text template feeding an attribute that holds a list (a list's `items`) is refused; `returnMode: 'value'` fixes it.
|
|
15
|
+
- Warning `default-content-beside-children`: a `button` whose placeholder "Button" would print beside its children.
|
|
16
|
+
- New: `bindTemplate(to, source, template, { category, returns })`; `visible: { source, template }`; a `compact`
|
|
17
|
+
breakpoint key (tablet and mobile at once); handles flag `repeated` (inside a list row) and `boxless` (a provider
|
|
18
|
+
with no tag); catalogues `elementLeafTypes` and `elementDefaultAttributes`.
|
|
19
|
+
- Defensive by default: every field of a space, page, layout, element, binding and step is checked against what its
|
|
20
|
+
type declares — an unknown key, an enumerated attribute outside its values (`subType: 'h7'`, declared per element with
|
|
21
|
+
`valuesOf`), text where a flag or a list is read, an unknown binding category or transformer, a step or transformer
|
|
22
|
+
param its catalog does not take (or of the wrong type), a flow with no trigger, a link or `navigate` to a page id that
|
|
23
|
+
does not exist, a URL/`mailto:`/`tel:` in page mode, a controlled list with nothing to render, two pages at one
|
|
24
|
+
address, an empty id, and a CSS value that is empty or breaks out of its declaration are all refused. An attribute a
|
|
25
|
+
built-in element never reads is now refused (was a warning). New warnings: `unknown-element-type` (name plugin types
|
|
26
|
+
with `authorSpace(space, { pluginTypes })`), `overlay-starts-open`, `provider-without-source`, `colour-without-dark`.
|
|
27
|
+
- `computed` on a space: values declared once and read anywhere as `{{ computed.<name> }}` (published as the global
|
|
28
|
+
source `computed`). `notifications` on a space: the toast colours and radius.
|
|
29
|
+
- `didYouMean` also suggests a candidate that contains what was written (`template` → `twigTemplate`).
|
|
30
|
+
- The decompiler drops a step param its action does not take, moves `settings.computed` to `computed`, and repairs a
|
|
31
|
+
full URL in a page-mode link to `mode: 'external'`, each reported as a correction.
|
|
32
|
+
- The skill gains `lists.md`, `plugins.md`, `authoring-errors.md` and a Recipes section (held by a test), and corrects
|
|
33
|
+
the attribute template scope, the Twig subset, the filter list, trigger payloads, offline data, `isEmpty`,
|
|
34
|
+
`loadStrategy` and `keepState`.
|
|
35
|
+
|
|
36
|
+
## One linter for every writer
|
|
37
|
+
- `lintSpace({ schema, style }, catalogs)` is the document linter everything is held to: `authorSpace` (through
|
|
38
|
+
`validateSpace`), `validateTemplate`, the MCP and the server's publish gate read a space with the same rules and the
|
|
39
|
+
same messages. The per-rule checks that `authorSpace` and the MCP each kept are gone.
|
|
40
|
+
- New rule `binding-target-unknown`: a binding onto an attribute its element never reads (`data-*`, a misspelt name)
|
|
41
|
+
is refused — the value arrived and nothing showed it. `className` stays bindable.
|
|
42
|
+
- The MCP lints the draft of every batch (`lintDraft`) and holds each element it touches to it; an issue already there
|
|
43
|
+
is labelled pre-existing. A binding onto an element that does not exist is now refused there too (the structural pass
|
|
44
|
+
runs with the source catalogue). Its own attribute and `{{ variable }}` checks for built-in types are gone — the lint
|
|
45
|
+
knows every attribute — so an unknown prop or an unknown variable is an error, not a warning.
|
|
46
|
+
- New rule `callback-key-unknown`: an element `setState`/`toggleState` writing a field its target never reads (or a
|
|
47
|
+
state other than `visibility` / `styleSelectors.<selector>`) is refused. The params of the callbacks every element
|
|
48
|
+
answers to are held to their spec (`vocabulary.sharedCallbacks`), and step params are read with their defaults
|
|
49
|
+
filled in, as the runtime reads them — `autoDismissTimeout: 'soon'` is caught though `autoDismiss` was left out.
|
|
50
|
+
- `lintSpace` has one test per code, and a test that fails when a rule is added without one.
|
|
51
|
+
- `validateTemplate` tells a binding onto a provider left behind once (`TEMPLATE_BINDING_OUT_OF_SCOPE`), no longer also
|
|
52
|
+
as `UNRESOLVED_BINDING_SOURCE`.
|
|
53
|
+
- `@plitzi/sdk-schema`: `REFERENCE_ERROR_CODES` and `isIntegrityError` tell a reference left dangling from a broken tree.
|
|
54
|
+
- MCP: `plitzi_validate`, `plitzi_apply` and `plitzi_render` share one pipeline (`draftBatch`), so validate answers
|
|
55
|
+
exactly what apply would. A structural error a batch introduces blocks it wherever it lands; one already in an
|
|
56
|
+
untouched element no longer blocks every edit. Its own checks that the lint now makes (first node a trigger, param
|
|
57
|
+
types, a missing step target, a setState key on a built-in type) are gone.
|
|
58
|
+
- Builder: a write the server refuses is now put back in the editor (the check never matched, so a refused change
|
|
59
|
+
stayed on screen and the next save built on it); only a write the server never answered is retried. The unused
|
|
60
|
+
`urgent` queue — which nothing ever processed — `count` and `getIsProcessing` are gone.
|
|
61
|
+
- Builder: a problems button in the header lists what is wrong with the saved space (re-read whenever the save queue
|
|
62
|
+
drains); each issue selects its element. Snapshot opens that list instead of publishing while there are errors, and
|
|
63
|
+
shows it when the server refuses a publish with `SPACE_INVALID`. New builder query `SpaceIssues` (`TSpaceIssue`,
|
|
64
|
+
`TSpaceIssues`).
|
|
65
|
+
|
|
66
|
+
- `fixSpace(documents, catalogs?, codes?)` and `FIXABLE_CODES`: the issues with a single reading are fixed on a copy
|
|
67
|
+
— a URL in a page-mode link or `navigate`, an attribute or step param that is a typo (renamed) or is never read
|
|
68
|
+
(dropped), `'true'`/`'false'` where a flag is read, a global callback on the wrong module, a utility on an element,
|
|
69
|
+
a visibility binding in `attributes`, a binding onto an attribute nothing reads, a misspelt transformer, an overlay
|
|
70
|
+
that starts open, a state key with `state.` in it — each reported as a line. Held by a test per code. The builder's
|
|
71
|
+
problems list offers "Fix N automatically" (server mutation `SpaceFixIssues`; each issue says whether it is
|
|
72
|
+
`fixable`).
|
|
73
|
+
- The linter's source scope is what the runtime walks: an element sees the providers around it and, past its page,
|
|
74
|
+
the layout around the slot it renders in — no longer any provider anywhere in a layout.
|
|
75
|
+
- Builder: a template that cannot be read is said under the field while it is typed, in the binding transformer and
|
|
76
|
+
flow step editors.
|
|
77
|
+
|
|
78
|
+
## One tree walk, one tree writer
|
|
79
|
+
- `@plitzi/sdk-schema/helpers/elementTree`: `parentChain`, `renderContext` and `descendants`, cycle-safe. The runtime's
|
|
80
|
+
data-source visibility, the builder's reveal, `authorSpace` and the linter all walk the tree through it.
|
|
81
|
+
- `FlatMap` loses what nobody called: `validate`/`isValid`/`assertValid` (the validation lives in `validateSchema`),
|
|
82
|
+
`getElement`, `elementIdConflict`, `takenIds`, `renameConflict`, `parentTree`, `childTree` and the unused statics.
|
|
83
|
+
`moveElement` refuses a move into the element's own subtree without walking forever on a cyclic document, and a move
|
|
84
|
+
into another page or layout carries the subtree's `rootId`.
|
|
85
|
+
- The MCP writes the tree through `FlatMap` (create, move, delete) like every other writer; a move into the element's
|
|
86
|
+
own descendant is refused with the reason.
|
|
87
|
+
- `schemaToWire`/`schemaFromWire` (sdk-shared `network/spaceEvents`): a whole schema on the live channel has `flat` as
|
|
88
|
+
a list; the builder reads `SPACE_UPDATED` through `schemaFromWire`.
|
|
89
|
+
|
|
90
|
+
- `FlatMap` no longer goes through lodash `get`/`set` with string paths: every read and write is typed. `addElement`
|
|
91
|
+
and `moveElement` share one placement step and validate before they write — an insert that is refused leaves no
|
|
92
|
+
element behind, and an anchor its parent does not list is refused rather than landing the element before the last
|
|
93
|
+
sibling. A template's base element has no `parentId` (it was `null`, which the type does not allow).
|
|
94
|
+
- Removed what nothing used: the MCP's `isActionOp`/`isConnectorOp`/`pageStylesUri`; in the builder the empty module
|
|
95
|
+
barrels, the pending `Integrations` stub, `PluginSettingsForm` and the code commented out around it,
|
|
96
|
+
`useInfiniteGraphQL`, a second `formatTime`, `ToggleItem`, `ButtonVoice`, and `SpaceContext`/its provider.
|
|
97
|
+
- Builder resources: one preview (`ResourceContent`) for a resource on its way up, in the list and in its details — the
|
|
98
|
+
list's own copy of the plugin card is gone — and one card for images and videos (`ResourceMedia`). A video was
|
|
99
|
+
dragged as an image and kept its remove button under the cursor while dragged, and so did any other file; both fixed.
|
|
100
|
+
- `elementsByRoot` (and `RootElements`) in `@plitzi/sdk-schema/helpers/elementTree`: the element count grouped by the
|
|
101
|
+
page or layout that holds it. The builder's quota panel and the server's usage API each carried a copy.
|
|
102
|
+
- Builder: a plugin removed from the resources leaves the elements panel while it is open, and one installed shows up in
|
|
103
|
+
it (the panel read the component registry once); removing a plugin no longer takes every other plugin's stylesheet
|
|
104
|
+
with it. Asking to remove a plugin that is still placed says on which pages, and how many of its elements will show
|
|
105
|
+
as not found.
|
|
106
|
+
- `rootName` in `@plitzi/sdk-schema/helpers/elementTree`, and `elementsByRoot` takes an optional filter: the same
|
|
107
|
+
per-page grouping, narrowed to the elements asked about.
|
|
108
|
+
- Builder: the model picker's ↑/↓ and ↵ do what its footer says (they were swallowed). Gone: the space setting `head`,
|
|
109
|
+
which was never wired, a connectivity listener that only logged, and commented-out code across the builder, the SDK
|
|
110
|
+
app and the packages.
|
|
111
|
+
- The plugin marketplace is removed. Nothing in the builder opened it any more; a space's plugins are still installed
|
|
112
|
+
from its resources. Gone with it: the builder's `Marketplace` module and the `integrations` placeholder panel,
|
|
113
|
+
`PluginsContextValue.fetch`, and the builder query `Plugins` (`@plitzi/sdk-shared/network/graphql/builder/Queries/PluginsQuery`).
|
|
114
|
+
The server drops the catalog behind it — the `Plugins`/`Plugin` queries, `/api/plugins` and its six tables.
|
|
115
|
+
- No debug logging left in shipped code: `stringToArray` logged every call and the transition editor every change;
|
|
116
|
+
`BlockJsx` and plugin loading report their failures as errors.
|
|
117
|
+
|
|
118
|
+
## Runtime
|
|
119
|
+
- Attribute `{{ tokens }}` resolve against every source around the element — a list row, a provider, `state`, `auth`,
|
|
120
|
+
`navigation.currentPageId` — not only variables and route params. Only authored values are interpolated: a value a
|
|
121
|
+
binding wrote is data and is never evaluated.
|
|
122
|
+
- The `twigTemplate` binding transformer takes `returnMode: 'value'`, answering a single `{{ expression }}` with its
|
|
123
|
+
value (`processTwigValue`): a filtered array, a number, a boolean.
|
|
124
|
+
- `keepState` no longer deletes what it kept on the next load: before the `auth` source is published the owner is
|
|
125
|
+
unknown, not "the browser".
|
|
126
|
+
- A `formControl` outside a `form` renders and works on its own (own value, `onChange`, follows `defaultValue`), and
|
|
127
|
+
its hooks no longer run conditionally.
|
|
128
|
+
- Modals and dialogs are fixed to the viewport, as tall as their content up to the screen; their close control is a
|
|
129
|
+
button. `openModal` metadata that parses as a non-object (`'42'`) is kept as `content`.
|
|
130
|
+
- `button` no longer takes its accessible name from `content`.
|
|
131
|
+
- `apiContainer.isEmpty` reads a plain `query` answer by what arrived.
|
|
132
|
+
- `container` accepts `h1`–`h6` tags, for a heading made of parts.
|
|
133
|
+
- `setState` keeps decimals for `number` and has a `json` type for objects and lists; `runServerAction.input` and
|
|
134
|
+
`webHook.body` are declared as the objects they take (param type `json`).
|
|
135
|
+
- `addNotification`'s `appearance` is spelled correctly (was `appeareance`; existing flows need the key renamed).
|
|
136
|
+
Notifications follow the space's theme and font.
|
|
137
|
+
- An `image` accepts `loadMode: 'auto'`, which the builder offers.
|
|
138
|
+
- `getStateManager()` gains `subscribe`.
|
|
139
|
+
- Routes: a slug with more than one `{{param}}` matches (only the first was converted).
|
|
140
|
+
- A plugin with several elements (`plitzi pack plugin` with more than one folder) draws each of them: every element but
|
|
141
|
+
the main one rendered the main element's component, since all of them load from the one module.
|
|
142
|
+
- `createServer({ allowPrivatePluginHosts })`: a schema plugin may be read from a private address — a development
|
|
143
|
+
machine's bucket on localhost. Off by default: a plugin's address is typed by whoever edits a space.
|
|
144
|
+
- Dev tools: with the panel collapsed, the page scrolls the document as it does in production.
|
|
145
|
+
- The base stylesheet has no invalid declarations: `markdown` fills its box (`height`/`width: 100%` were quoted
|
|
146
|
+
strings the browser dropped), and `text` no longer declares a size it never applied — it still inherits its own.
|
|
147
|
+
|
|
148
|
+
## Twig
|
|
149
|
+
- `starts with`, `ends with`, `//`, `**`, `?:`, `a ? b`, subscripts on array and hash literals.
|
|
150
|
+
- `format` is sprintf: flags, width, padding and precision (`'%02d'`, `'%.2f'`, `'%-8s'`).
|
|
151
|
+
- `date` tokens `D`, `y`, `h`, `g`, `A`, `a`, and `\` to print a character as it is.
|
|
152
|
+
- `inspectTemplate(template)` reports what a template would read past and the names it reads.
|
|
153
|
+
|
|
154
|
+
## CLI
|
|
155
|
+
- With nobody at the terminal (an agent, CI), `create` stops and prints each missing choice — package manager, mode,
|
|
156
|
+
source, and the key for a cloud project — as a question the agent must put to the user, and offers no way around
|
|
157
|
+
it: it used to end with "or with --yes to take the defaults", and agents took that exit instead of asking. `--yes`
|
|
158
|
+
now only answers for a person at a terminal; a script passes the flags.
|
|
159
|
+
- Every project gets `tsx`, so `npm run author` works on a fresh checkout, and `npm run shot -- /path --width 390
|
|
160
|
+
--scheme dark` takes a full-page screenshot.
|
|
161
|
+
- The generated visual test skips list rows and providers with no tag.
|
|
162
|
+
- The example plugin takes its props as attributes; in a client project its numbers come from `public/data/stats.json`
|
|
163
|
+
through a provider — the offline-data pattern.
|
|
164
|
+
|
|
165
|
+
## Examples: `browser` and `self-hosting`
|
|
166
|
+
- `examples/` is two folders: `browser` (a space on your page, no server) and `self-hosting` (a server of your own,
|
|
167
|
+
from a server-rendered page to a space's runtime). What a space on the platform does — Ceniza, Tremor, Fieldnotes,
|
|
168
|
+
Pizarra, the server and render actions, a template — is a seeded space on the platform rather than an example here.
|
|
169
|
+
|
|
170
|
+
## A space's visitors: signing in, and what they may do
|
|
171
|
+
- `settings.visitorRoles`: a space declares its visitor roles and what each gives (`{ author: ['postPublish'] }`),
|
|
172
|
+
published and exported with it. `authorSpace` refuses a malformed one; `checkVisitorRoles` / `visitorAccess`
|
|
173
|
+
(`@plitzi/sdk-shared/auth/visitorRoles`) are the one reading of them.
|
|
174
|
+
- `userProvider: 'server'` (`ServerAuthProvider`): a space whose people sign in THROUGH its page server, by redirect —
|
|
175
|
+
the session is a cookie on the space's host; `login` goes out, `logout` asks the server.
|
|
176
|
+
- `createServer({ signIn })`: `GET /auth/sign-in` and its callback — register this host with an OAuth 2.1 authorization
|
|
177
|
+
server, PKCE, state in a `__Host-` cookie, the code redeemed server to server and handed to `exchangeCredential`.
|
|
178
|
+
- OAuth: `issueToken` is told the grant's `redirectUri`; a server mounted under a prefix sends people back to, and posts
|
|
179
|
+
its grant screen to, its own `/authorize` (it resolved `/authorize` against the issuer and dropped the prefix).
|
|
180
|
+
- `safeRedirectTarget` refuses `/\host`, which a browser reads as `//host`.
|
|
181
|
+
- `spaceKvPatterns(spaceId)` (`@plitzi/sdk-server/actions`): every key a space's `kv` holds, to let them all go.
|
|
182
|
+
- Builder: a **Visitors** panel — the space's roles, and who holds them, given by email.
|
|
183
|
+
|
|
184
|
+
## MCP: what the agent is told
|
|
185
|
+
- The guide explains every operation `plitzi_apply` takes — moving and deleting elements, schema variables, design
|
|
186
|
+
tokens and fonts had no word in it — and names every resource the server registers, `plitzi://fonts/{env}` (now
|
|
187
|
+
registered, so it can be discovered), the layouts, the server tasks and the render guide among them. A test fails when
|
|
188
|
+
an operation, a resource or a global source is missing from it.
|
|
189
|
+
- The global binding sources, the transformer names and the data-sources scope note are generated from
|
|
190
|
+
`@plitzi/sdk-authoring`, the catalogs the linter checks a save against. They listed `collection` and `space`, which
|
|
191
|
+
no longer exist, and missed `variables`, `host`, `theme` and `computed`.
|
|
192
|
+
- The guide says what an `apiContainer` publishes in each mode: through a connector `.records`/`.record`/`.pageInfo`,
|
|
193
|
+
with a browser `query` the body under `.data` and the HTTP `.status`; both `.isLoading`, `.isEmpty`, `.hasError`.
|
|
194
|
+
- Server actions have their own section; the tool list names `plitzi_preview`, `plitzi_screenshot` and
|
|
195
|
+
`plitzi_render`; the co-worker prompt lists the layouts, fonts, connectors and actions.
|
|
196
|
+
- The guide's connector examples read a record's fields under `values` (`list_posts.item.values.title`,
|
|
197
|
+
`.record.values.<field>`), which is where the engine puts them; they bound to nothing as written.
|
|
198
|
+
- The `plitzi-render` skill's first example no longer sets the `min-width: 0` its own rules say is unneeded; the
|
|
199
|
+
authoring skill lists `host` and `computed` among the globals.
|
|
200
|
+
- New guides: `docs/en/mcp.md` — connecting an agent, how it works a space, what the server guarantees, what is
|
|
201
|
+
deliberately left open — and `docs/en/connectors.md`, which only existed in Spanish. RFC 0002 is removed now that it
|
|
202
|
+
shipped. The repository READMEs, `claude.md`, onboarding and repository-structure list `apps/mcp`, `apps/cli`,
|
|
203
|
+
`apps/desktop` and `sdk-authoring`, and no longer `sdk-collections`.
|
|
204
|
+
|
|
205
|
+
## Preview a published revision
|
|
206
|
+
- The draft preview takes an optional `revision` (`PreviewRequestBody.revision`, carried by `PreviewClient.render`):
|
|
207
|
+
a published revision of `env` rendered instead of its latest, so a capture can show one exact version — the one a
|
|
208
|
+
reviewer approved rather than whatever was published after. Ignored for `main`; a revision that is not a positive
|
|
209
|
+
integer is a 400.
|
|
210
|
+
|
|
211
|
+
## Builder: the server's reason, not "network not available"
|
|
212
|
+
- A query the server refused shows the server's own message in its toast — "the storage provider refused this CDN's
|
|
213
|
+
credential", "there is no bucket …" — where it used to say "Query … Failed" and, wrongly, "Network Not Available".
|
|
214
|
+
Only a request that got no answer is reported as a network problem.
|
|
215
|
+
- A CDN that cannot be listed says so in its panel, with the reason and a way to choose or fix its credential, instead
|
|
216
|
+
of an empty list that read as "nothing uploaded".
|
|
217
|
+
- Every builder preview renders again — element templates from a CDN, directory items, transformer and AI previews.
|
|
218
|
+
The preview's render-settings scope inherited nothing (a nexus scope is isolated unless `inherit="live"`), so it held
|
|
219
|
+
`render` alone and each element failed with "Element … not found". `useRenderOverride` now says the scope must be
|
|
220
|
+
live, and a test holds it.
|
|
221
|
+
|
|
222
|
+
## Interactions: "Propagate Event" does what it says
|
|
223
|
+
- A click, hover or focus trigger with **Propagate Event** off — the default — now answers the event for the elements
|
|
224
|
+
around it too: a button inside a clickable card runs the button's flow and not the card's as well. It used to decide
|
|
225
|
+
only `preventDefault`, so every clickable ancestor ran its flow after the inner one.
|
|
226
|
+
- The DOM event itself is not stopped: a component's own handler (a dropdown opening from a click inside it), the dev
|
|
227
|
+
tools' element picker and anything listening above the space still receive it. `preventDefault` is unchanged.
|
|
228
|
+
- To keep the old behaviour on one element, turn Propagate Event on for the inner trigger.
|
|
229
|
+
|
|
230
|
+
## Change history
|
|
231
|
+
- Every save of a space's schema and style is recorded by the server — who made it (a person, an agent, the co-worker,
|
|
232
|
+
the autofix), from where, and each element, class, token or font it touched, before and after. Read-only; kept per
|
|
233
|
+
plan. See `docs/en/history.md`.
|
|
234
|
+
- Builder: a **History** panel — the timeline, newest first, with saves folded into rows, each unfolding into a
|
|
235
|
+
field-by-field diff that links to its element; filters by who made it, the selected element, and since the last
|
|
236
|
+
snapshot; each row numbered (`#50`), and each published revision marked right above the last change it includes
|
|
237
|
+
("Revision 4 · includes up to #50") — `upToSeq` on the snapshot markers `SpaceChanges` returns.
|
|
238
|
+
- `@plitzi/sdk-shared/history`: `diffSchema`, `diffStyle`, `describeChange` (a save as lines: "Added text “hero” to
|
|
239
|
+
page “test”", with the parent's bookkeeping left out), `fieldChanges`, `sameValue`, `jsonCopy` and the
|
|
240
|
+
`SpaceChange` vocabulary; builder query `SpaceChanges` (`TSpaceChanges`, `TSnapshotMarker`).
|
|
241
|
+
- `@plitzi/sdk-mcp`: `saveSchema`/`saveStyle` receive an `SSRWriteContext` (the member, one batch per tool call), and an
|
|
242
|
+
optional `getChanges` adapter serves `plitzi://changes/{env}` and `plitzi://changes/{env}/{id}`.
|
|
243
|
+
|
|
244
|
+
## Kept state: what is never kept, and where keeping is decided
|
|
245
|
+
- New space setting **`transientState`**: top-level `runtime.state` keys that are never kept, even with `keepState` on.
|
|
246
|
+
They are not written, not brought back — an entry kept before a key was declared transient does not restore it —
|
|
247
|
+
and a value one of them holds survives the restore, which lands late (after hydration, once auth settles) and used
|
|
248
|
+
to undo anything set before it. Built on `@plitzi/nexus` 1.3.0's `partializePath`/`mergePath`; every `@plitzi/nexus`
|
|
249
|
+
range here is `^1.3.0`. Covered by
|
|
250
|
+
`e2e/tests/sdk/keptState.spec.ts` across a real reload. For demos, open panels, walkthrough steps: state that must start fresh every visit.
|
|
251
|
+
`authorSpace` refuses a list that is not one, an empty key and a dotted one (naming the top-level key to write), and
|
|
252
|
+
warns `transient-state-without-keep-state` when `keepState` is off. The builder's State Settings has the field, and
|
|
253
|
+
the MCP's `patchSettings` takes it.
|
|
254
|
+
- **A page no longer takes `keepState` / `stateStorage`.** The runtime only ever read them from the space's settings,
|
|
255
|
+
so on a page they promised something nothing did. `authorSpace` refuses them with where they go; reading an older
|
|
256
|
+
document back drops them and reports it.
|
|
257
|
+
- The authoring skill no longer suggests resetting kept state from `onPageLoad` — the restore lands in the middle of
|
|
258
|
+
that flow — and the MCP guide describes `keepState` as what it is: `runtime.state`, not element state.
|
|
259
|
+
|
|
260
|
+
## A step's params keep their type, and a failure can say why
|
|
261
|
+
- **A param is its value's own type, never one guessed from its text.** Step params — the browser's flows and a
|
|
262
|
+
server action's steps alike — were rendered to text and read back as JSON, so a text field holding `1234` reached
|
|
263
|
+
the next step as the number 1234: a password field declared `text` then saw no password at all, and a board's lock
|
|
264
|
+
was REMOVED where one was being set. One resolver now serves both sides (`processTwigParam` in
|
|
265
|
+
`@plitzi/sdk-shared/helpers/twigWrapper`): a param that is one `{{ expression }}` is that expression's value as it
|
|
266
|
+
is; a JSON filter alone (`{{ saved|json_encode }}`) is the value it encodes; text around the tokens is JSON only
|
|
267
|
+
when it makes an object or array document, and otherwise text. Converting is left to whoever declares a type —
|
|
268
|
+
`setState`'s `type`, an action's input fields — which already did. **Behaviour change:** a template that renders a
|
|
269
|
+
number-looking string now hands on the string (`'{{ flag ? "1" : "" }}'` is `'1'`, not `1`).
|
|
270
|
+
- **A failed server action can tell the page why.** A step's own error message still never leaves the server — it
|
|
271
|
+
can hold a query or a credential's name — but a task may now throw **`ActionRefusal`** (from
|
|
272
|
+
`@plitzi/sdk-server/actions`) with a message written for the person on the page, and `flow.fail` takes
|
|
273
|
+
**`tellCaller`**. The run answers `status: 'failed'` with that message as `error` (in a stream's last frame too),
|
|
274
|
+
and `runServerAction` hands it to the flow as `{{ step.error }}` — as it now also does for a refusal before the run
|
|
275
|
+
began. The step's preview lists `reason` and `error`, so the builder offers them.
|
|
276
|
+
- **One resolver for a step's params, in the browser and on the server** (`resolveStepParam`). The server only
|
|
277
|
+
resolved what `hasValidToken` calls a token, so a param written as a condition or an object literal
|
|
278
|
+
(`{{ { "id": run.id }|json_encode }}`) reached its task as the template text — an action's `output` built that way
|
|
279
|
+
failed with "not valid JSON". Both sides now run any template syntax, keep the value's type, and read a value that
|
|
280
|
+
is itself a template again up to the same ceiling.
|
|
281
|
+
- **Fixed: a value with a quote or a line break broke a param written as a JSON document.**
|
|
282
|
+
`{ "city": "{{ values.city }}" }` gave the step its raw text instead of the document when a visitor typed `Say "hi"`
|
|
283
|
+
or pressed Enter. A value printed inside one of the document's string literals is now escaped for it; a value
|
|
284
|
+
printed outside them is printed as before — it IS the JSON value there.
|
|
285
|
+
- **`when` around a step that already has a `when` adds to it.** It used to replace the inner condition, so the step
|
|
286
|
+
ran whenever the outer one held and nothing said so. Two `and` groups become one; otherwise both are kept, nested.
|
|
287
|
+
- The schedules example's first-paint test assumed the digest was less than a day away, and failed at weekends.
|
|
288
|
+
|
|
289
|
+
## A page with many elements keeps its frame rate
|
|
290
|
+
- **`useEventBridge` stopped re-subscribing on every render.** Its `callbacks` and `params` defaulted to `= {}` in the
|
|
291
|
+
signature — a new object each render, and both are the effect's dependencies — so every element on a page (each
|
|
292
|
+
subscribes through `withElement`) took its subscription off and put it back on every render it went through.
|
|
293
|
+
- **`EventBridge.off` is constant time.** Whether a module had events left was a `for…in` over its keys, which still
|
|
294
|
+
enumerates all of them on an object that constant deletions have turned into a dictionary: N elements
|
|
295
|
+
re-subscribing cost N × N. A count per module replaces it. `EventBridgeProps.events` is typed as the bridge holds
|
|
296
|
+
it — only the modules and events that have a listener — and the package has tests now.
|
|
297
|
+
- **A flow reads a space's computed values once per change, not once per step.** `liveSources` evaluated every
|
|
298
|
+
computed value before every step and every `when`; it now keeps the last evaluation and reuses it over the same
|
|
299
|
+
snapshots of the state and the global sources.
|
|
300
|
+
- **An element subscribes to the paths it reads, not to the sources they are in.** A binding on `computed.tool`
|
|
301
|
+
rendered its element again whenever any computed value changed — on a board, the tool in hand changing was the
|
|
302
|
+
whole page drawn again. Bindings, `when` rules and attribute templates now subscribe to each path they name
|
|
303
|
+
(`templatePaths` in `@plitzi/sdk-shared/helpers/twigWrapper`). **Fixed** on the way: a binding whose template also
|
|
304
|
+
read another source (`{{ theme.resolved }}` beside the bound value) and a binding shown while a `when` held were
|
|
305
|
+
never told those changed.
|
|
306
|
+
- **A computed value that comes out the same keeps its object.** Every computed value is evaluated again whenever the
|
|
307
|
+
state changes, and a list or a record came out a new object each time, so every element reading one rendered again
|
|
308
|
+
for a change to something else: on Pizarra, the elements library's hundred and sixty stars reading the favourites.
|
|
309
|
+
`evaluateComputed` takes the previous evaluation and keeps each value that is deep-equal to it, and the whole when
|
|
310
|
+
none changed. Switching tools there rendered 1,375 elements; it renders 19. Drawing a shape: 2,833 → 129.
|
|
311
|
+
- **A step that finishes synchronously hands over to the next at once.** Every step used to wait a microtask, so a
|
|
312
|
+
flow of synchronous steps rendered once per step; React now batches them into one.
|
|
313
|
+
- Measured on Pizarra with 300 notes and a marquee over all of them: from 27 frames over 50 ms (the worst 330 ms,
|
|
314
|
+
React's development build) to 60 fps with one 88 ms frame when the selection panels mount (production build).
|
|
315
|
+
- **A test can count what an interaction renders.** `inspectRenders(page, act, { max })` in
|
|
316
|
+
`@plitzi/sdk-authoring` answers every element that rendered, how often, what changed for it and which store paths
|
|
317
|
+
were written; over `max`, `problems` names the elements that rendered most. It reads the render tracing the SDK
|
|
318
|
+
already keeps under `debugMode`, now published as `window.plitziTracing` while it is on (`TracingReader` in
|
|
319
|
+
`@plitzi/sdk-shared/store/tracing`: `lastCommitId`, `commitsSince`). The skill's new `performance` reference says
|
|
320
|
+
what makes an element render, what a flow costs and where to look when something is slow.
|
|
321
|
+
- **`pressShortcut(page, 'mod+z')`** presses a shortcut written as `onKey` writes it, with the keys the PAGE listens
|
|
322
|
+
for: `mod` from its user agent. A driver's "Control or Meta" asks the machine running the suite, so a Mac driving an
|
|
323
|
+
emulated desktop Chrome pressed ⌘ at a page listening for Ctrl.
|
|
324
|
+
|
|
325
|
+
## Conditions, and what authoring catches
|
|
326
|
+
- **Behaviour change: a visibility, once its data answers, is a yes or a no.** A value written to `visibility` by a
|
|
327
|
+
binding is now read the way `not` reads it — `false`, `0`, an empty text, an empty list are a no — and a no is
|
|
328
|
+
written too. Before, only a truthy value was written, so an element shown once stayed shown when its condition came
|
|
329
|
+
back empty, and a template that printed `0` showed it. A condition's template no longer needs `? 'true' : 'false'`:
|
|
330
|
+
`{{ source is defined and source is empty }}` is enough. A source that has not answered yet still leaves the element
|
|
331
|
+
as it starts, so a flag nobody has set keeps what it controls on screen, as spaces rely on.
|
|
332
|
+
- **New warning `form-value-compared-to-blank`.** A `when` asking whether a submitted field (`….values.x`) `=` or
|
|
333
|
+
`!=` `""`: a field nobody typed in is not in `values` at all, so it never matches. The warning names
|
|
334
|
+
`operator: 'empty'` / `'notEmpty'`, which take a missing value and `""` alike.
|
|
335
|
+
- A `when` with `isBinding` comparing a computed value with a path from the trigger is pinned by a test.
|
|
336
|
+
- The examples write their conditions without `? 'true' : 'false'` — a flag is `visible: 'computed.presenter'`, its
|
|
337
|
+
inverse `'!computed.hasFrames'`, a condition its own expression — and author with no warning: Pizarra's chat no
|
|
338
|
+
longer sends an empty line (`notEmpty`, which the new warning found), and the blog's sidebar stops sticking on
|
|
339
|
+
phones (`tablet-rule-skips-mobile`).
|
|
340
|
+
|
|
341
|
+
## Builds and caches
|
|
342
|
+
- **`apps/sdk`'s production build no longer deletes the vendor bundles.** `emptyOutDir` emptied `dist`, where
|
|
343
|
+
`vite.vendor.config.ts` builds React and its kin: a production build left pages whose script 302'd to HTML. The build
|
|
344
|
+
now empties what it made and leaves the vendor files.
|
|
345
|
+
- **A cached plugin is rebuilt when its source changed, whatever its version says.** The plugin manager records a
|
|
346
|
+
content digest of every file a bundle was built from and compares it when a process first finds the bundle — in
|
|
347
|
+
production as in development. A deployment rebuilt from new source under the same version used to serve the
|
|
348
|
+
previous bundle. Bundles cached before this are built once more.
|
|
349
|
+
- **A space's plugins are kept by what they are, not by their name and version.** The page server kept every plugin a
|
|
350
|
+
render named — a space's external plugins, a deployment's `pluginSources` — under `name@version`, and the first
|
|
351
|
+
source to arrive held that key while the process ran: a plugin published again under the same version was served
|
|
352
|
+
from its old, missing URLs until a restart, and two spaces each with a `board@1.0.0` of their own were both served
|
|
353
|
+
whichever the process had met first. The key now carries the source's identity (`name@version+<digest>` of where
|
|
354
|
+
its files are, how they are served and its props); invalidating a release covers those keys too, and a plugin named
|
|
355
|
+
by a render never answers for a bare name, which stays the plugins the server was set up with.
|
|
356
|
+
- **The examples are linted** with the packages' rules (`examples/eslint.config.mjs`, a `lint` script in each), and
|
|
357
|
+
what the rules found is fixed — among it, index reads the types called defined, a hook-named step helper, and the
|
|
358
|
+
Permissions API assumed present. `docs/` and the skills' markdown are hand-wrapped and listed in `.prettierignore`.
|
|
359
|
+
|
|
360
|
+
## Kept state the first paint shows is drawn by the server
|
|
361
|
+
- Kept state lives in web storage, which only the browser reads, and is restored after hydration — so anything kept
|
|
362
|
+
that changes what is DRAWN (the tool a toolbar shows as last picked, a name in an avatar, a panel left off) was
|
|
363
|
+
painted with the space's defaults and swapped a moment later. New space setting **`paintedState`**: the kept keys the
|
|
364
|
+
first paint shows. They are written to a cookie as well (`plitzi_<webId>_painted`, with the port in the name as the
|
|
365
|
+
debug cookie has it); `prepareRender` reads it, renders with the declared keys only and hands the page the same
|
|
366
|
+
values as its starting `runtime.state` (the SDK's `state`), so it hydrates onto them and nothing is swapped. The HTML
|
|
367
|
+
cache is keyed by that cookie. `e2e/tests/server/ssr/paintedState.spec.ts` checks the raw HTML and the hydration.
|
|
368
|
+
- The cookie carries its owner, like the kept entry in web storage: the server cannot tell whose it is for a space with
|
|
369
|
+
its own sign-in, so once auth has settled the page drops what it rendered with from somebody else's cookie, and an
|
|
370
|
+
account change no longer returns to them. Over 3 KB it is not written (the dev tools say so), and the stale one is
|
|
371
|
+
removed.
|
|
372
|
+
- `authorSpace` checks it like `transientState` — a list of top-level keys — refuses a key that is both painted and
|
|
373
|
+
transient, and warns `painted-state-without-keep-state`. The builder's State Settings, the MCP's `patchSettings`
|
|
374
|
+
(validated the same way), its guide, the authoring skill and `docs/en/authoring-spaces.md` describe it.
|
|
375
|
+
- One cookie reader for both halves of the SDK (`cookieFromHeader` in `@plitzi/sdk-shared/helpers/cookies`), which the
|
|
376
|
+
theme cookie now uses as well.
|
|
377
|
+
|
|
378
|
+
## Testing an authored space: one call, every problem
|
|
379
|
+
- **`inspectPage(page, handles, options?)`** (`@plitzi/sdk-authoring`): the open page, checked whole — every element it
|
|
380
|
+
owes present and visible (its own and those of the layouts around it), images arrived, nothing scrolling sideways, no
|
|
381
|
+
text in the colour painted behind it. Returns every problem at once, each naming the element and why
|
|
382
|
+
(`display:none on "panel"`), and retries like an assertion. Driver-agnostic (anything with `evaluate`), no new
|
|
383
|
+
dependency. `inspectDocument(page)` runs the page half for a page whose space is not in hand.
|
|
384
|
+
- `onScreen(handles, page, { elements, ignore })`: what a freshly opened page owes. Page handles carry their `layout`,
|
|
385
|
+
layout handles theirs.
|
|
386
|
+
- `singlePageSpace(body, space?)` and `withElement(spec, id, patch)`: a one-page space, and the same space with one
|
|
387
|
+
element changed — on the SPEC, so a variant is validated like any space.
|
|
388
|
+
- `authorSpace(spec, { allow: [{ code, element, why }] })`: a fixture that breaks a check on purpose names that break.
|
|
389
|
+
It comes back in `warnings` with the reason; an entry that matches nothing is refused.
|
|
390
|
+
- Fixed: `defineAction` with several triggers chained them one after another, so a run through the first executed the
|
|
391
|
+
second as a step. Every way in now heads the same chain.
|
|
392
|
+
- Fixed: `provider-without-source` warned on a provider that names a `connector`.
|
|
393
|
+
- The image element says when it drew its fallback: `data-plitzi-failed="<src>"`. The fallback loads, so to a browser
|
|
394
|
+
a broken image looked loaded. The fallback is now state, keyed by the source: a new `src` gets its own attempt.
|
|
395
|
+
- The CLI's generated visual test uses `inspectPage`.
|
|
396
|
+
- The sample space (`examples/shared-space`) no longer sizes itself by the window: embedded beside a host's sidebar
|
|
397
|
+
(`03-react-component`) it overflowed by the sidebar's width. Its RSC section is named `rsc-section`.
|
|
398
|
+
|
|
399
|
+
## A page server that starts in less memory
|
|
400
|
+
- `sdk-shared` imports date-fns one function per subpath, and its locales one by one: `from 'date-fns'` loaded all
|
|
401
|
+
826 of its modules (and `date-fns/locale` every language) wherever the date helpers were imported, which is on every
|
|
402
|
+
page server — the largest single cost of starting at all.
|
|
403
|
+
- `isDate(value, format)` moved to `@plitzi/sdk-shared/helpers/isDate` (still exported from `@plitzi/sdk-shared/helpers`):
|
|
404
|
+
it needs date-fns `parse`, which alone costs about 100 MB, and nothing that only formats dates should load it. It is
|
|
405
|
+
no longer exported from `helpers/formatDate`.
|
|
406
|
+
- Needs `@plitzi/plitzi-ui` with the same fix in its `formatDate` (the QueryBuilder evaluator a server loads imported
|
|
407
|
+
date-fns whole, and `parse` for one fixed format). Measured on the SSR example: resident memory at rest 308 → 177 MB.
|
|
408
|
+
- Everything a page runs is in `plitzi-sdk.js` again, with no `withElement-<hash>.js` or `rolldown-runtime-<hash>.js`
|
|
409
|
+
beside it. The plugin loader's dynamic imports split a chunk off, which every host serving the SDK by name had to
|
|
410
|
+
know about. The vendor build has `codeSplitting: false`; the SDK's keeps every module its entry reaches statically in
|
|
411
|
+
the entry (see "A lighter SDK" for the one chunk it does split).
|
|
412
|
+
|
|
413
|
+
## A page server that fits in half a CPU and 256 MB
|
|
414
|
+
- **Fixed: a page server leaked on every request until it ran out of memory.** The auth/deployment middleware chain
|
|
415
|
+
was rebuilt per request, and `basicAuthMiddleware` starts a credential cache with a sweep timer — each request left
|
|
416
|
+
one behind that its timer kept alive for good (about a kilobyte a request; a 256 MB container died after a few
|
|
417
|
+
hundred thousand). The chain is built once per server, and the credential cache finally caches.
|
|
418
|
+
- **A cached page is compressed once.** The render cache keeps the Brotli and gzip bodies beside the HTML, so a hit is
|
|
419
|
+
a copy instead of a compression — which was 87% of a hit's CPU. `res.send(body, { compressed })` takes the store to
|
|
420
|
+
fill; `CompressedBodies` and the cache's `CachedPage` are exported.
|
|
421
|
+
- **Log levels: `logLevel` (`silent` < `error` < `warn` < `info` < `debug`).** One threshold for everything a server
|
|
422
|
+
says, process-wide. Default `error` — in production only what went wrong is written — and `info` with `devMode`.
|
|
423
|
+
- Requests below the threshold are not turned into events; a 5xx or a request that threw is an `error`, the rest
|
|
424
|
+
`info`. A refused action is a `warn`, a run that did not complete an `error`.
|
|
425
|
+
- The server's own lines (`listening on`, plugin builds, manifest fetches, failures in adapters, RSC, actions, auth
|
|
426
|
+
flows) are `kind: 'message'` events on the same `logger`, where they used to go straight to the console. With
|
|
427
|
+
no `logger` they still go to the console.
|
|
428
|
+
- The dev metrics line is `debug`.
|
|
429
|
+
- `serverLog` (`error`/`warn`/`info`/`debug`/`enabled`/`emit`), `logLevelOf` and `isLogged` are exported.
|
|
430
|
+
`createRunLogger`/`createRejectLogger` are held to the same threshold.
|
|
431
|
+
- **`createJsonAdapters` parses a file once per version of it**, not once per request (a tenth of a render's CPU on a
|
|
432
|
+
real space). A file edited by hand is read again by its mtime and size; a save drops the copy it replaced. Every
|
|
433
|
+
request shares the parsed space, as they already did with `createCloudAdapters`.
|
|
434
|
+
- A server with `devMode` off that runs without `NODE_ENV=production` says so once, at `error`: React chose its
|
|
435
|
+
development build from `NODE_ENV` when it was imported, and renders about 40% fewer pages a second with it. The
|
|
436
|
+
README no longer claims `devMode` defaults from `NODE_ENV` — it defaults to off.
|
|
437
|
+
- `@plitzi/sdk-server` and `@plitzi/sdk-mcp` no longer bake `process.env.NODE_ENV` in at build time. The published
|
|
438
|
+
build always read `"production"`, whatever the process was started with; a server reads it from its process now.
|
|
439
|
+
|
|
440
|
+
## Static files compressed, and compressed once
|
|
441
|
+
- **Fixed: static files went out uncompressed.** Every body sent as a `Buffer` skips compression (the rule that
|
|
442
|
+
keeps fonts and images intact), and static files were read as Buffers — so the SDK bundle, its vendor and its
|
|
443
|
+
stylesheet travelled whole: 2.7 MB, 1 MB and 220 KB on a first visit. Text files (scripts, stylesheets, JSON, SVG,
|
|
444
|
+
plain text) are compressed now; images and fonts still go out as the bytes on disk.
|
|
445
|
+
- Each is compressed once per version of the file and kept, within 16 MB, least recently served first out — and read
|
|
446
|
+
from disk only when an encoding it has not been compressed to yet is asked for. `res.send` takes a function for such a
|
|
447
|
+
body: `send(() => read(), { compressed })` reads it only when the stored form is missing.
|
|
448
|
+
- **Two Brotli qualities.** `compression.brotliQuality` (default 2, was 4) is for a body compressed on every request;
|
|
449
|
+
`compression.keptBrotliQuality` (default 6) for one compressed once and kept — a cached page, a static file. Measured
|
|
450
|
+
on a quarter core: 4 cost a tenth of the pages a second to save half a kilobyte each; 6 makes the SDK bundle 10%
|
|
451
|
+
smaller than 4 for the same memory, where 9 needs ~40 MB more than a 128 MB server has.
|
|
452
|
+
- The space embedded in a rendered page is serialized once per space object instead of on every render — a tenth of a
|
|
453
|
+
render's CPU spent producing the same string.
|
|
454
|
+
- `sdk-shared`: the dev-tools console no longer keeps logs on a server, where nobody could ever receive them and they
|
|
455
|
+
held other visitors' state; and stamps a log by hand instead of through date-fns. A page renders ~25% faster on a
|
|
456
|
+
quarter core with both.
|
|
457
|
+
- Examples: the servers take `HOST` (still loopback by default) and derive `devMode` from `NODE_ENV` — a copy of an
|
|
458
|
+
example run in production no longer runs in development mode.
|
|
459
|
+
- `bench/` (private): load and footprint benchmarks of the self-hosted servers under hardware profiles, with
|
|
460
|
+
baselines — `yarn bench`, see its README.
|
|
461
|
+
|
|
462
|
+
## Self-hosted servers run compiled, without a transpiler
|
|
463
|
+
- **`plitzi create` (server mode) runs on Node alone.** `start` is `node src/main.ts` — Node 22.18+ strips the types
|
|
464
|
+
itself — and `tsx` is gone: its loader thread cost a page server more memory than the server, ~270 MB to start where
|
|
465
|
+
the same server starts in ~90. For production, `build` (`tsc -p tsconfig.build.json`) emits `dist/` and `start:prod`
|
|
466
|
+
runs `node dist/main.js`: stripping types keeps a TypeScript transformer in the process for its whole life (~10 MB),
|
|
467
|
+
compiled JavaScript does not. The project's `tsconfig` holds the code to what stripping needs
|
|
468
|
+
(`allowImportingTsExtensions`, `verbatimModuleSyntax`, `erasableSyntaxOnly`), `engines` says `>=22.18`, relative
|
|
469
|
+
imports name their `.ts` file, the plugin path resolves from the project root (the same from `src/` and `dist/`),
|
|
470
|
+
and the server listens on `HOST` (loopback by default).
|
|
471
|
+
- The examples run the same way: `node src/main.ts`, `node --watch-path=./src` while editing, no `tsx`.
|
|
472
|
+
- `@plitzi/nexus` 1.3.1, now required (`^1.3.1`) by every package that uses it: `useStore` runs one set of hooks for a
|
|
473
|
+
single path and a list of paths, instead of both side by side with the idle one disabled (a page allocates about a
|
|
474
|
+
quarter less to render); `useStoreGetter` reads a function entry from the latest render instead of an earlier
|
|
475
|
+
closure with the same source; and the path caches no longer evict every path of a page of a few hundred elements
|
|
476
|
+
just before reading it again.
|
|
477
|
+
- `bench/`: `cli-server` measures a project as `plitzi create` writes it, built and run with `start:prod`; portals are
|
|
478
|
+
mounted into the container and recorded in every result; `edge-96` and `edge-64` probe the floor.
|
|
479
|
+
- `sdk-elements`: an element that binds nothing no longer resolves bindings on every render, and one whose attributes
|
|
480
|
+
hold no template no longer builds template data and copies its attributes to interpolate none — the work every
|
|
481
|
+
element of a page did on each render, for nothing, however few of them bind or template.
|
|
482
|
+
- `bench/`: the memory probe is compiled to JavaScript before a run (loaded as TypeScript, it put Node's type stripper
|
|
483
|
+
into every server measured, ~10 MB counted as the server's); `--repeat N` starts a target cold N times and keeps
|
|
484
|
+
each phase's median run, since on Apple silicon a container runs on a fast or a slow core for its whole life.
|
|
485
|
+
|
|
486
|
+
## A page server on every core
|
|
487
|
+
- **`workers`: one process per core.** Node renders on one thread, so a server used one core however many the
|
|
488
|
+
machine had. `workers: 'auto' | <n> | false` (and `SDK_SERVER_WORKERS`) runs the server in that many processes on
|
|
489
|
+
one port; `'auto'` is the default under `NODE_ENV=production`, one process anywhere else. The count follows the
|
|
490
|
+
cores the process may use — a container's CPU quota included — and a number above them is lowered, with a warning.
|
|
491
|
+
One process (workers off, one asked for, one core) is the server exactly as before: nothing forked, nothing wrapped.
|
|
492
|
+
- **The workers are one server.** The in-memory defaults — an action's `kv`, the job queue, draft previews, the
|
|
493
|
+
sign-in rate limit — are kept once, by the process that was started, and every worker reaches the same copy over
|
|
494
|
+
the cluster channel; a store the deployment supplies is used as it is. The scheduler and the job consumers run in
|
|
495
|
+
one worker. `server.cache.invalidate()`, `server.plugins.register()` and `server.plugins.invalidate()` reach every
|
|
496
|
+
worker. Plugins are built once, before the workers start, and their files are written atomically (a temporary file
|
|
497
|
+
renamed over the target), so no process reads a half-written one.
|
|
498
|
+
- **A worker that dies is replaced**, and takes its jobs with it to the replacement. Past one death per worker a minute
|
|
499
|
+
the replacements wait longer each time (half a second, doubling, up to thirty), so a crash on every request is not a
|
|
500
|
+
fork loop; a replacement that cannot start is retried while the others keep serving. A worker that dies before any
|
|
501
|
+
has served stops the server with a non-zero exit, and a server killed outright takes its workers with it.
|
|
502
|
+
- **Fixed: `server.cache.invalidate({ spaceId | environment | hostname })` matched nothing.** It read the page cache's
|
|
503
|
+
key by positions it no longer had once the key gained the visitor's token, theme and dev-tools choice in front; a
|
|
504
|
+
publish webhook that invalidated a space cleared no page. The key is now read by the same list that writes it.
|
|
505
|
+
- `PluginManager.forget(name?, version?)` drops what a process remembers of a plugin and leaves the files: what an
|
|
506
|
+
invalidation in another worker does.
|
|
507
|
+
|
|
508
|
+
## Fewer silent failures, less friction for an agent
|
|
509
|
+
- **New lint warnings**, each with the fix in its message, held by `authorSpace`, the builder's problems list, the
|
|
510
|
+
publish gate and the MCP server alike:
|
|
511
|
+
- `server-data-without-rsc`: a `runtime: 'server'` provider with a `connector` or `action` in a space that does not
|
|
512
|
+
turn server data on — it rendered its mock data and nothing said why. Add `rsc: { enabled: true }`.
|
|
513
|
+
- `route-param-undeclared`: `navigation.routeParams.x` read on a page whose slug has no `:x` — always empty. Prose
|
|
514
|
+
elements that only mention one are not read.
|
|
515
|
+
- `form-control-unnamed` / `form-control-name-taken`: a control in a form with no `name` never reached the form's
|
|
516
|
+
values, and two with one name wrote over each other.
|
|
517
|
+
- `overlay-never-opened`: a modal or dialog that starts hidden and that no step opens.
|
|
518
|
+
- **New lint error `list-items-ignored`** (fixable): a `list` with items (bound or written) and a `source` other than
|
|
519
|
+
`'controlled'` rendered its children once and never read them — one empty row where the rows should be. `fixSpace`
|
|
520
|
+
sets `source: 'controlled'`. It found the Feature Lab seed's catalogue rendering one blank card.
|
|
521
|
+
- **The page server logs a plugin it has nothing for**: a `custom` element whose `renderType` has neither a component
|
|
522
|
+
in the render nor a bundle for the browser is reported at `error`, once per space and type, naming the element and
|
|
523
|
+
`plugins` + the deployment's `pluginNames`. It used to render "Custom Component … Not Found" and say nothing.
|
|
524
|
+
- **A `webHook` that writes with an empty body sends `{}`**, not the JSON text `""` a JSON endpoint refuses with a 400.
|
|
525
|
+
- **`webHook` takes `headers`** (by name; a template per value): an API key, an `Accept`, an idempotency key.
|
|
526
|
+
`Authorization` stays `authorizationToken`'s and the content type follows the body, so neither can be named twice.
|
|
527
|
+
Headers are part of what a cached read is keyed by.
|
|
528
|
+
- **Fixed: a `webHook` sending a file could not be read by any server.** It set `multipart/form-data` by hand, without
|
|
529
|
+
the boundary the parts are split by; `fetch` writes the type itself now.
|
|
530
|
+
- `sdk-authoring`: **`setFieldValue(target, name, value)`**, the step that fills one field of a form — clearing a code
|
|
531
|
+
field after a failed attempt, prefilling one from a binding.
|
|
532
|
+
- **Fixed: a step parameter like `"012345"` reached the step as the number `12345`.** A value is read as a number only
|
|
533
|
+
when it reads back as the same text; a code, a postcode or an id with leading zeros stays text.
|
|
534
|
+
- `fixSpace(space, catalogs, codes, elements)`: `elements` narrows the fixes to some elements. It now clones only the
|
|
535
|
+
elements it changes, and with nothing to fix answers the schema it was handed.
|
|
536
|
+
- **MCP `plitzi_apply` / `plitzi_validate`:**
|
|
537
|
+
- What was already wrong with an element a batch changes is fixed on the way, where it has one reading, and every
|
|
538
|
+
fix is said in `warnings`. The fixes run on the space before the batch, so the batch's own mistakes are still
|
|
539
|
+
refused.
|
|
540
|
+
- An old issue is told from a new one by its code on its element, not only by its message, so a page rename or a
|
|
541
|
+
changed suggestion no longer makes an old issue on an untouched element look new and block the batch. One more
|
|
542
|
+
of an issue than before is still the batch's.
|
|
543
|
+
- The space as it was is read only when the result has something to classify: ~15% less time a batch on the
|
|
544
|
+
largest spaces.
|
|
545
|
+
|
|
546
|
+
## A server-rendered page carries its stylesheet once, and its data as JSON
|
|
547
|
+
- **The compiled stylesheet no longer travels twice.** The runtime `<style>` a page renders already holds
|
|
548
|
+
`style.cache`, verbatim, and the hydration payload carried it again. The server now leaves it out
|
|
549
|
+
(`styleCacheInDocument: true` in the payload) whenever the page gives it back byte for byte — no `{{ token }}`, no
|
|
550
|
+
`<`, no `\r` or `\0` — and `render()` reads it back from that `<style>` before hydrating, so the browser draws the
|
|
551
|
+
same stylesheet and the store holds the same cache. The cache sits between two CSS comments in the stylesheet it was
|
|
552
|
+
always in: the element, its place in the tree and the cascade are unchanged. `plitzi.com`'s home: 2.82 → 2.53 MB,
|
|
553
|
+
350 → 313 KB with per-request Brotli.
|
|
554
|
+
- **The payload is a `<script type="application/json">` block**, parsed with `JSON.parse` and removed once read,
|
|
555
|
+
instead of a JavaScript literal inside the bootstrap module. On a first load of that page Chromium parses it in
|
|
556
|
+
6.7 ms instead of 15.4 ms; the page no longer holds the space twice.
|
|
557
|
+
- `sdk-shared/style`: `markStyleCache`, `styleCacheTravelsInDocument`, `styleCacheFromDocument`, `RUNTIME_STYLE_ID`.
|
|
558
|
+
|
|
559
|
+
## The account console, and telling devices apart
|
|
560
|
+
- **`auth.plitzi.*/account` is an account console**, no longer a column beside the sign-in's brand pane: identity along
|
|
561
|
+
the top, sections down the side (tabs on a phone) — **Profile** (username, and email changed through a link to the
|
|
562
|
+
new address), **Security** (password, and closing the account), **Devices**, **Connections**.
|
|
563
|
+
- **Devices are listed by device, not by session.** `GET /devices/sessions` groups sessions by what they were created
|
|
564
|
+
from and answers this device apart; a browser that signed in forty times is one row saying "40 sessions". The list
|
|
565
|
+
had shipped and drawn every session — 1,218 for one account a test suite signs in as — inside a box that scrolled.
|
|
566
|
+
- **Every device is named for what it is**: the application as it registered over OAuth (`Plitzi CLI on carlos-mbp`,
|
|
567
|
+
`Plitzi Desktop on studio`), a browser and its system, an automated client (Playwright, headless Chrome) — never a
|
|
568
|
+
raw user agent. Sessions record their address and **when they were last used** (at most every five minutes, on the
|
|
569
|
+
lookup a request already makes). A sign-in deletes the account's dead sessions.
|
|
570
|
+
- **AI connectors are one per application**: Claude and ChatGPT granted the same space are two connectors, each named
|
|
571
|
+
and disconnected on its own (`GET /devices/connectors`).
|
|
572
|
+
- `sdk-server`: **`issueToken(user, target, context)`** — the OAuth layer tells a deployment which application a
|
|
573
|
+
credential is for (`client_name`, `software_id` kept from registration), the request it is issued on, and on a
|
|
574
|
+
renewal the credential it **replaces**, so a native client renewing daily stays one session. **`revokeToken`**, new
|
|
575
|
+
and optional: `/revoke` now ends what the grant issued, not only its renewal — signing the CLI or the desktop app out
|
|
576
|
+
removes it from the device list at once. Sessions carry `app` and `lastActiveAt` (`SessionApp`,
|
|
577
|
+
`SESSION_ACTIVITY_RESOLUTION_SECONDS`, `activityDue`); the MySQL store migrates to schema step 4. The session's
|
|
578
|
+
address is recorded (Express `req.ip`, or the proxies' headers); `SSRRequest.ip`.
|
|
579
|
+
- `sdk-server`: **`deletionBlockers(userId)`** — an account is refused deletion (409, with the list) while it owns
|
|
580
|
+
something that would be lost with it. `plitzi-sdk-server` refuses while it is the only owner of a workspace holding
|
|
581
|
+
spaces, other members or a live plan; an empty personal workspace is deleted with the account.
|
|
582
|
+
- **Fixed: a flow's `navigate` to a page in a folder went to the wrong address** — `/security` for `/account/security`,
|
|
583
|
+
and the home page for a folder's index page. It resolves the page's full path now, as a link does
|
|
584
|
+
(`navigationTarget` in `sdk-shared/navigation`).
|
|
585
|
+
- **Fixed: changing your email sent a mail with no body** (the `email-change` template did not exist) and a link to no
|
|
586
|
+
page. It has both now (`/confirm-email` in the auth space).
|
|
587
|
+
- **Fixed: account emails interpolated values unescaped** — a username with markup in it arrived as markup in a mail
|
|
588
|
+
sent from Plitzi.
|
|
589
|
+
- The visual suite signs every test's session out when it ends; it had left over a thousand on one account a day.
|
|
590
|
+
|
|
591
|
+
## Two-step sign-in
|
|
592
|
+
- **An account can turn on a second step** (an authenticator app, TOTP) from Security in the account console: a QR
|
|
593
|
+
code drawn by the server, the key to type by hand, one code to confirm it works, and ten recovery codes shown once.
|
|
594
|
+
Turning it off asks for the password. `GET /account/mfa/setup` answers the QR of an enrolment in progress, never of
|
|
595
|
+
one already confirmed, and is never cached. The secret is encrypted at rest (`user_mfa`) and recovery codes are kept
|
|
596
|
+
as digests, each good once.
|
|
597
|
+
- **Signing in then asks for the code** on its own page (`/two-factor` in the auth space), which takes a recovery code
|
|
598
|
+
as well. `sdk-auth`: a login answered with `mfaRequired` is a **`MfaChallenge`** (`{ ok: false, reason: 'mfa',
|
|
599
|
+
mfaToken }`), not a session — the provider read it as one and ended signed out. The space names where the code is
|
|
600
|
+
sent with **`mfaUrl`**; the `auth.login` step's mode **`'mfa'`** sends `{ mfaToken, code }` there.
|
|
601
|
+
- **Fixed: an AI connector ended from the account console came back.** Ending it — one connector, "sign out everywhere
|
|
602
|
+
else", or the space's own credentials — deleted its row, and the host's next renewal put the row back. A renewal of
|
|
603
|
+
a connector that has been ended now ends too (`invalid_grant`).
|
|
604
|
+
- **Fixed: a new account made by signing in with GitHub or Google could not use its session.** It was created active
|
|
605
|
+
but not verified — the column's default — and an unverified account holds no session, so every request after the
|
|
606
|
+
sign-up answered 401. The provider verified the address, so the account is created verified; a migration verifies
|
|
607
|
+
the accounts already created that way. The credential exchange made accounts the same way and is fixed with it.
|
|
608
|
+
- **Fixed: a code could sign in twice.** A TOTP code is valid for its whole window, and nothing remembered that one had
|
|
609
|
+
been used: seen over a shoulder or lifted by a phishing page, it opened a second session within that window. The
|
|
610
|
+
step of the last code accepted is kept (`MfaRecord.lastUsedStep`; `totpStep(secret, code)` in `sdk-server/auth`
|
|
611
|
+
says which step a code matched) and nothing up to it is taken again — the code that confirms the enrolment
|
|
612
|
+
included (RFC 6238 §5.2). The MySQL store migrates to schema step 5 (`account_mfa.last_used_step`).
|
|
613
|
+
- **Fixed: an account with a second factor was locked out by signing in often.** A right password answered with a
|
|
614
|
+
challenge was not reported as a success, so the sign-in limit counted it as a failure (ten in five minutes).
|
|
615
|
+
- `sdk-server`: a numeric field in an `/auth` body is read as its text (a code typed into a number field was dropped
|
|
616
|
+
as missing).
|
|
617
|
+
- **Fixed: signing out of the auth space could loop between two pages** (over a thousand navigations in six seconds):
|
|
618
|
+
for a moment the session published to the page was still the one the provider had just ended. Once the provider says
|
|
619
|
+
nobody is signed in, the page is told nobody is (`publishedSession`).
|
|
620
|
+
- Builder: the space's provider settings take **`mfaUrl`** and **`sessionExchangeUrl`**; both could only be set from
|
|
621
|
+
code.
|
|
622
|
+
|
|
623
|
+
## Requests from other sites
|
|
624
|
+
- **Fixed: any website could act as a signed-in person against the api and server roles.** CORS answered every origin
|
|
625
|
+
with `Access-Control-Allow-Credentials`, and the session cookie is `SameSite=None`: a page the person visited could
|
|
626
|
+
read their account and write to it. Credentialed CORS is now for `PLATFORM_ORIGINS` only; every other origin is
|
|
627
|
+
still answered, without the person's cookies. A published site calling its own space is unaffected — it presents
|
|
628
|
+
its space credential, and a customer domain's sign-in exchange is served by its own page server, same-origin.
|
|
629
|
+
- **A write carried by a session cookie from another site is refused** (403, `reason: 'foreign'`), judged by Fetch
|
|
630
|
+
Metadata and the exact `Origin`: CORS keeps another site from reading, but a plain form POST needs no preflight.
|
|
631
|
+
Platform origins pass, and so do the origins the request's space credential declares. Bearer requests carry no
|
|
632
|
+
victim's cookie and are never asked. `sdk-server`: **`createOriginGuardMiddleware(csrf, { allowedFor, exempt,
|
|
633
|
+
errorKey })`** and `csrf.crossSite(carrier, alsoAllowed?)`; `plitzi-sdk-server` runs it on both roles and refuses
|
|
634
|
+
to start with CSRF switched off.
|
|
635
|
+
- **Analytics beacons are sent as text** (`text/plain`), which the collector reads as JSON. A beacon always goes with
|
|
636
|
+
credentials, and a JSON one is preflighted: from a customer's domain it would have been refused with the rule above.
|
|
637
|
+
As text it needs no preflight. **Fixed:** the `fetch` fallback (a batch over the beacon's size) arrived empty — in
|
|
638
|
+
`no-cors` the browser sends text whatever the header says, and the collector did not read it.
|
|
639
|
+
- **Fixed: the CSRF middleware answered 500 on Node 24.** It built its carrier by spreading the request, and `headers`
|
|
640
|
+
there is a getter on the prototype that a spread does not copy. The carrier is built field by field (`carrierOf`).
|
|
641
|
+
|
|
642
|
+
## Plugins from the CLI
|
|
643
|
+
- **`plitzi add plugin [names...]`** adds elements of your own to the project you are in — one, several at once, or one
|
|
644
|
+
at a time as the need comes. It asks what to call each, what the builder shows and what it is for, checks every folder
|
|
645
|
+
is free before writing any, and writes a folder per element the way `@plitzi/sdk-elements` writes its own:
|
|
646
|
+
the component, `declaration.ts` (its `type`, the `triggers` it fires, the `callbacks` it answers to and the element
|
|
647
|
+
the builder adds — data only), `Settings.tsx` (its builder panel) and `index.ts`
|
|
648
|
+
(`Object.assign(Component, declaration, { pluginSettings: Settings })`). Each kind of project is answered as itself:
|
|
649
|
+
- a project `plitzi create` wrote gets it in `src/plugins`, registered by itself (`start:dev` restarts onto it); when
|
|
650
|
+
its space lives in Plitzi, it is told to place it in the builder;
|
|
651
|
+
- a project from before plugins were found by folder is told the exact line its `src/main.ts` list needs;
|
|
652
|
+
- a plugin package gets it in `src/` and in `src/elements.ts` / `src/declarations.ts` — rewritten only while they are
|
|
653
|
+
still the lists the CLI wrote;
|
|
654
|
+
- any other project is asked for the folder (`--dir`) and told how to register it for `render()`,
|
|
655
|
+
`<PlitziSdk.Plugin>` and a page server.
|
|
656
|
+
A name that would make a built-in element's type (`button`, `form`) is refused.
|
|
657
|
+
- **`plitzi create [directory] --plugin`** writes a plugin package: its elements, a Vite preview that renders them
|
|
658
|
+
inside a space, and a visual test of each. A package holds as many elements as it needs (`--elements
|
|
659
|
+
legend,price-tag`, or asked): the first is published as the plugin, the rest as its `plugins`. It builds nothing
|
|
660
|
+
itself — no bundler config, no build dependency — since `plitzi pack plugin` is the one place a plugin is built.
|
|
661
|
+
It ships its source (a page server compiles an element from it) and exports `elements` for a project registering
|
|
662
|
+
them itself. `--name`, `--title`, `--description` and `--owner` answer what it otherwise asks; inside a repository it
|
|
663
|
+
offers the folders that repository keeps its packages in, installs with its package manager and leaves its install
|
|
664
|
+
settings alone. It replaces the `plitzi-plugin-template` repository, which is deprecated.
|
|
665
|
+
- **`plitzi pack plugin [folders...]`** builds a plugin — a package's elements, or element folders of any project, a
|
|
666
|
+
self-hosted one included: one ES module (esbuild; React and the SDK kept out for the page to provide, images and
|
|
667
|
+
fonts kept in, since a page imports it from a blob URL), `plugin-manifest.json` written from the elements'
|
|
668
|
+
declarations with each file's integrity hash, and the zip the builder takes under Resources — checked against how
|
|
669
|
+
the upload and the builder read it. A package also gets its type declarations, written with its own TypeScript. A
|
|
670
|
+
declaration missing what the manifest needs is refused by name.
|
|
671
|
+
- **`plitzi login`, `logout`, `whoami`, `space` and `upload plugin`.** The CLI signs in the way the desktop app does:
|
|
672
|
+
in the browser, through the platform's native OAuth (loopback redirect, PKCE), keeping the session and a refresh
|
|
673
|
+
token in `~/.config/plitzi/connection.json` (0600), renewed on its own and revoked on `logout`. It is connected to
|
|
674
|
+
**one space at a time**, chosen on the grant screen (`plitzi space`, the `space` scope) — choosing again replaces the
|
|
675
|
+
connection and revokes the one before, and no command takes a space of its own. `upload plugin` puts the zip
|
|
676
|
+
`pack plugin` left on one of that space's CDNs and installs it, signing in or choosing the space in the browser
|
|
677
|
+
first when either is missing. Every builder open on the space loads the new version on the spot.
|
|
678
|
+
- `sdk-server`: `grantTargets(user, { scope })` — the grant screen offers what the scope a client asked with chooses
|
|
679
|
+
among — and the token response carries the chosen `target` (RFC 6749 §5.1), on renewal too, so a native client knows
|
|
680
|
+
what it was granted.
|
|
681
|
+
- `plitzi-sdk-server`: the native sign-in offers the person's spaces to a client asking with the `space` scope, and
|
|
682
|
+
re-checks access to the chosen one whenever it issues a session — a refresh after losing access ends the grant.
|
|
683
|
+
`GET /spaces/:id/cdns` lists a space's CDNs (never their credentials) and `POST /spaces/:id/cdns/:identifier/plugins`
|
|
684
|
+
takes a plugin's zip, uploads it the way the builder does (one `uploadResource` now serves both) and installs it on
|
|
685
|
+
`main`, keeping the settings of a plugin already there; the change is recorded in the space's history as the person's.
|
|
686
|
+
- **Plugins change live in every builder open on the space.** Adding, updating or removing one — from a builder, or
|
|
687
|
+
from `plitzi upload plugin` with none open — is announced on the space's one channel (Redis pub/sub and the GraphQL
|
|
688
|
+
subscription every other edit travels on), as `SPACE_ADD_PLUGIN`, `SPACE_UPDATE_PLUGIN` and `SPACE_REMOVE_PLUGIN`.
|
|
689
|
+
The other builders load, swap or drop it, its sub-plugins and its stylesheet included; the builder that made the
|
|
690
|
+
change does not apply it twice.
|
|
691
|
+
- **Fixed: a plugin's settings could not be saved.** `SpaceUpdatePlugin` required the plugin's address and wrote the
|
|
692
|
+
settings empty every time. Both are optional now and what is not sent is kept; settings that are not an object, or
|
|
693
|
+
a plugin the space does not have, are refused.
|
|
694
|
+
- **Fixed: a plugin uploaded from Windows was refused** ("Type file not supported"). Chrome and Edge on Windows send a
|
|
695
|
+
zip as `application/x-zip-compressed`, and both the builder and the upload accepted only `application/zip`.
|
|
696
|
+
- **A project `plitzi create` writes registers every folder of `src/plugins` by itself**, under its name in camelCase
|
|
697
|
+
— `readdirSync` in server mode, `import.meta.glob` in client mode — so a new element needs no line of `src/main.ts`.
|
|
698
|
+
- **A new project is formatted by its own Prettier** once installed, so the first commit is already in its style. Only
|
|
699
|
+
into a folder that was empty: `--force` never reformats work that was there.
|
|
700
|
+
- `sdk-shared` / `plitzi-sdk`: **`PluginDeclaration`**, the declaration type of an element of somebody's own — what
|
|
701
|
+
`ElementDeclarationData` says of any element, plus the `content` a manifest publishes. Exported as a type from
|
|
702
|
+
`@plitzi/plitzi-sdk`.
|
|
703
|
+
- **Fixed: a plugin on its own host rendered "Not Found".** `fetchManifest` sent `Content-Type` on a GET, which made the
|
|
704
|
+
browser ask the host's permission first; a host that allows plain cross-origin reads — the usual CORS setting of a
|
|
705
|
+
bucket — refused, and the element never loaded. It asks with `Accept` now, and answers nothing for a 404 rather than
|
|
706
|
+
for a body that failed to parse.
|
|
707
|
+
- `sdk-authoring`: `blankSpaceSource({ plugin: { as: 'element' } })` hosts a plugin as an element of its own type — how
|
|
708
|
+
the builder adds one and how a space loading it from its manifest renders it, and takes a list to host several.
|
|
709
|
+
Strings in the copy are quoted the way
|
|
710
|
+
Prettier quotes them (`"Today's"`, not `'Today\'s'`), and a name with a backslash no longer breaks the file.
|
|
711
|
+
- e2e: `plugin-server` generates a package with the CLI, builds it, publishes it on a host of its own and checks a page
|
|
712
|
+
loads it from its manifest.
|
|
713
|
+
|
|
714
|
+
## Export as code: spaces from an older builder
|
|
715
|
+
- `sdk-authoring`: **`specFromSpace` reads a document an older builder keyed by ObjectId.** Before an element's id was
|
|
716
|
+
its name, `flat` was keyed by a Mongo ObjectId and the name lived in `idRef`; reading one kept the ObjectId, and
|
|
717
|
+
`authorSpace` refused it ("not one a binding, a template or a test can name"), so the builder's Export failed on
|
|
718
|
+
every such space. Each element takes its `idRef` back as its id — a positional `<type>-<n>` where it has none and
|
|
719
|
+
its key is not a valid id — with every reference repointed, and the rename is reported as `legacy-element-id`.
|
|
720
|
+
`withNamedIds` is exported so a caller comparing what it read compares it under the same names.
|
|
721
|
+
- `sdk-authoring`: a space with no pages is refused up front with a reason a person can act on, instead of the
|
|
722
|
+
authoring error about writing one.
|
|
723
|
+
- Builder: Export sends the space's plugin types, so an element a plugin provides is no longer reported as unknown.
|
|
724
|
+
|
|
725
|
+
## Outbound requests stay outside the cluster
|
|
726
|
+
- `sdk-server`: the `http.request` task and the connector engine refuse private destinations however they are written.
|
|
727
|
+
An address is judged by range, so private IPv4 written as IPv6 (`[::ffff:127.0.0.1]`, `[::]`) is refused, and so
|
|
728
|
+
are the multicast, CGNAT and reserved ranges. NAT64 and 6to4 addresses are judged by the IPv4 address they carry,
|
|
729
|
+
so an IPv6-only cluster still reaches public IPv4 APIs. A DNS name is no longer refused just because it starts like
|
|
730
|
+
an IPv6 prefix (`fcbarcelona.com`).
|
|
731
|
+
- Redirects are followed one hop at a time, and each destination is checked before anything is sent to it. A public
|
|
732
|
+
URL that redirects into the cluster is refused. A redirect that leaves the origin drops `Authorization` and `Cookie`.
|
|
733
|
+
|
|
734
|
+
## The grant screen says who is asking
|
|
735
|
+
- `sdk-server`: the OAuth grant screen names the client and the host the grant is sent back to ("an app on this
|
|
736
|
+
computer" for a loopback client). Anybody can register a client under any name, so the host is the part a person
|
|
737
|
+
can check. `OAuthConsentView` carries it as `client: { name, redirectHost, loopback }`.
|
|
738
|
+
- New `OAuthConfig.loopbackRedirectsOnly`: only `http://127.0.0.1` / `localhost` redirects are accepted, when a client
|
|
739
|
+
registers and again at `/authorize`. Turn it on when every client is a native app, above all with `directTokens`,
|
|
740
|
+
where the grant is the person's session.
|
|
741
|
+
|
|
742
|
+
## Draft previews: a secret, and one space
|
|
743
|
+
- `sdk-mcp`: the `/__preview` endpoint refuses every request when preview is enabled without a `secret`, and compares
|
|
744
|
+
the secret in constant time. It lives on the page server the public reaches, so "no secret" used to mean "no check".
|
|
745
|
+
**A deployment with preview on must set `preview.secret`**, and every caller must send it as `x-preview-secret`.
|
|
746
|
+
- `sdk-server`: a draft is only rendered for the space it was made from. `DraftPutOptions` and `DraftEntry` carry
|
|
747
|
+
`spaceId`, and a token presented under another space's host is ignored. A custom `DraftStore` has to keep it.
|
|
748
|
+
|
|
749
|
+
## One outbound rule, everywhere
|
|
750
|
+
- `sdk-server`: `@plitzi/sdk-server/kernel` exports `isBlockedHost` and `assertOutboundAllowed`, the rule the
|
|
751
|
+
`http.request` task and the connector engine follow.
|
|
752
|
+
- `sdk-mcp`: the widget proxy judges addresses with that rule instead of its own copy, which let IPv4 written as IPv6
|
|
753
|
+
(`[::ffff:127.0.0.1]`) through.
|
|
754
|
+
- `sdk-server`: a space's external plugin manifest is fetched through the same rule, redirects included.
|
|
755
|
+
|
|
756
|
+
## A signed-in visitor is rendered signed in, the day after too
|
|
757
|
+
- `sdk-server`: a page asked for once the access cookie has expired but renewal is still possible (the session hint
|
|
758
|
+
says so) is sent to renew first and comes back signed in, so the server renders the visitor the browser will end up
|
|
759
|
+
with. The HTML used to be the guest page, swapped for the signed-in one after boot, and a guest-only page was shown
|
|
760
|
+
and then redirected away from instead of answered with a 302.
|
|
761
|
+
- New `createServer({ sessionRenewal: { url } | false })`. On by default with `auth` (its own `/refresh`); without it,
|
|
762
|
+
name the endpoint — an absolute URL when another host serves `/auth`.
|
|
763
|
+
- The endpoint is `GET <basePath>/refresh?redirect=<page>`, served by `createServer({ auth })` and now also by
|
|
764
|
+
`mountAuthRoutes` (34 routes). It renews with the refresh cookie, writes the new session or ends a dead one, and
|
|
765
|
+
always sends the browser back — to a path, or to a host sharing the session's cookie domain (`/` otherwise).
|
|
766
|
+
- Only whole-tab navigations (`Sec-Fetch-Dest: document`) take part, on both halves: a renewal rotates the session,
|
|
767
|
+
and an `<img>` or a frame on another site must not be able to. Anything else on `GET /refresh` is a 405. A short
|
|
768
|
+
`<cookie>_renewing` cookie keeps a renewal that failed without ending the session from sending the visitor round
|
|
769
|
+
again.
|
|
770
|
+
- New in `@plitzi/sdk-server/auth`: `parseSessionHint`, `readSessionHint`, `sessionReturnTarget`,
|
|
771
|
+
`isDocumentNavigation`, `renewForNavigation`.
|
|
772
|
+
|
|
773
|
+
## Scheduled jobs: a store hiccup is not an incident
|
|
774
|
+
- `sdk-server`: a schedule sweep, reconcile, job claim or heartbeat that fails is reported by how long it has been
|
|
775
|
+
failing. The first failure is a one-line warning with its reason (it runs again by itself, and every pass is safe
|
|
776
|
+
to repeat); a pass still failing a minute later is the error, with the cause; the pass that ends such a streak says
|
|
777
|
+
so. A Mongo driver resetting its pool while the host was busy used to print an error with a stack on every pass it
|
|
778
|
+
caught. A deployment's own `onError` still receives every failure.
|
|
779
|
+
- Tests that ran slow under a busy machine: the CLI's type-declarations test (a real compile) has a timeout of its own,
|
|
780
|
+
and the workers test waits for every worker to listen rather than a fixed number of requests.
|
|
781
|
+
|
|
782
|
+
## Plugins held to their declarations, and fixes a full example found
|
|
783
|
+
- `authorSpace(space, { plugins: [declaration] })` (also `validateSpace`, `lintSpace`, `fixSpace`): a plugin handed over
|
|
784
|
+
as its declaration is checked like a built-in element — authored as its own type or hosted by
|
|
785
|
+
`custom({ renderType })`. A flow on an event it never fires, a step sent to an action it does not answer and an
|
|
786
|
+
attribute it does not read are refused, naming what it declares. `pluginTypes` stays as the lighter form.
|
|
787
|
+
- New step builders typed from a declaration: `declaredTrigger(declaration, 'onPick')` and
|
|
788
|
+
`declaredCallback(declaration, 'reset', { on: 'seats' })` — a name the declaration lacks is a compile error.
|
|
789
|
+
- A `custom` host whose component was not declared is no longer refused for flows on the component's own events: the
|
|
790
|
+
plugin template `plitzi add plugin` writes (`onCount`) could not be used in a flow as generated.
|
|
791
|
+
- `setState` accepts `type: 'json'` in authoring, as the runtime and the docs always did; a test holds the two lists
|
|
792
|
+
together.
|
|
793
|
+
- The plugin cache rebuilds when a plugin's entry moved (`Widget.ts` → `Widget/index.ts`) or a file it was built from
|
|
794
|
+
was deleted — a missing file used to count as unchanged, and a versioned plugin served the old bundle for good in dev.
|
|
795
|
+
- A plugin handed to the SDK after mount — one the server could not import, passed once hydration is done, or a
|
|
796
|
+
`<PlitziSdk.Plugin>` added later — now renders: the component registry was built once and never learned of it.
|
|
797
|
+
- `dropdown`: a click inside the popup no longer reaches the trigger and closes the menu (`closeOnClickPopup: false`
|
|
798
|
+
now keeps it open), and `closeOnClickBackground` closes on a click outside even without the blocking background.
|
|
799
|
+
- A space's `style.theme.default` is applied: the server paints a first visit in it, and the SDK starts there. The
|
|
800
|
+
theme cookie now records only a visitor's CHOICE — the theme a surface merely started in is not written — so a space
|
|
801
|
+
that changes its default reaches everybody who never chose.
|
|
802
|
+
- `button`, `text`, `markdown` and `heading` default line heights are ratios that land on the same pixels at their
|
|
803
|
+
default sizes, so a class that resizes the text keeps the proportion instead of a fixed 24px line.
|
|
804
|
+
- Skills: `plitzi-authoring` gains `reference/validation.md` (how `authorSpace` checks — first refusals one at a time,
|
|
805
|
+
then the linter's list at once — a one-file author script, and what it cannot see) and plugin guidance; `@plitzi/cli`
|
|
806
|
+
ships a `plitzi-cli` skill, copied into every project `plitzi create` writes and every plugin package.
|
|
807
|
+
|
|
808
|
+
## A flow's steps read the page as it is when they run
|
|
809
|
+
- **Behaviour change.** Every step of a flow reads the sources again when it runs, instead of the page as it was when
|
|
810
|
+
the trigger fired. A `when` or a `{{ state.x }}` after a `setState` sees the new value; one after a `delay`, a
|
|
811
|
+
server action or anything else that waits sees what changed meanwhile, including what the person did. `computed`
|
|
812
|
+
is evaluated again for each step over the state as it is then — read from the store, not from the copy the last
|
|
813
|
+
render left in `runtime.sources`.
|
|
814
|
+
- What this breaks: a toggle written as two `setState` steps under opposite `when` guards on the same key now flips
|
|
815
|
+
and flips back. `authorSpace` warns about it (`state-toggled-in-branches`) and names `toggleState`. The Plitzi
|
|
816
|
+
website's sidebar toggle was the one stored flow of that shape, and is one step now.
|
|
817
|
+
- `liveSources(sources, state, computedDefinitions)` (`@plitzi/sdk-shared/dataSource`) is what an element hands a
|
|
818
|
+
running flow.
|
|
819
|
+
|
|
820
|
+
## Keyboard shortcuts: `onKey`
|
|
821
|
+
- A trigger every element has, `onKey`, with one param: `keys` — one shortcut or several with commas (`'f'`,
|
|
822
|
+
`'shift+f'`, `'mod+k'`, `'plus, ='`, `'escape'`). Heard on the window while the element is mounted; ignored while
|
|
823
|
+
somebody types in a field unless Ctrl/⌘/Alt is held or the key is Escape; a matching press does not also do the
|
|
824
|
+
browser's default. The flow reads `{{ <step>.key }}`, the key pressed.
|
|
825
|
+
- Authoring: `onKey(keys)` refuses a shortcut that cannot fire where it is written; `lintSpace` reports one written in
|
|
826
|
+
the builder (`trigger-keys`).
|
|
827
|
+
- `@plitzi/sdk-shared/helpers/keys`: `parseKeys` and `keyPressCombo`, the two halves of matching.
|
|
828
|
+
|
|
829
|
+
## `plitzi create` projects check their own plugins
|
|
830
|
+
- A local project keeps `src/plugins/declarations.ts`, and every place it authors the space — the server or the
|
|
831
|
+
browser entry, `npm run author`, the visual test — passes it to `authorSpace(space, { plugins: declarations })`.
|
|
832
|
+
`plitzi add plugin` adds each new plugin's declaration to it (or, when the list was changed by hand, says what to
|
|
833
|
+
add). A flow on an event a project's plugin never fires is refused at authoring, as it is for a built-in element.
|
|
834
|
+
|
|
835
|
+
## Templates: `same as`, `divisible by`, and `null`
|
|
836
|
+
- `x is same as(y)` and `x is divisible by(n)` (with their `is not` forms) are Twig tests the interpreter now reads.
|
|
837
|
+
Before, `same` was read as a variable nobody set, so `x is same as(false)` held exactly when `x` was UNSET — silently
|
|
838
|
+
the opposite of what it says.
|
|
839
|
+
- `null` and `none` are literals, as in Twig, instead of names that resolved to nothing. After `is` they are still the
|
|
840
|
+
test (`x is null` holds for an unset value too).
|
|
841
|
+
|
|
842
|
+
## `whileRunning`: what a trigger fired again while its flow runs does
|
|
843
|
+
- A trigger's `whileRunning` is `skip` (the default and what always happened: the firing is ignored — no double
|
|
844
|
+
submit), `queue` (it runs after the one in progress, in order) or `parallel` (it runs at once). Authored with
|
|
845
|
+
`whileRunning('queue', onClick())`, offered on the trigger in the builder, carried by the MCP and by the export to code.
|
|
846
|
+
- The guard is now per FLOW rather than per event: one flow on a click still running no longer holds back another flow
|
|
847
|
+
on the same click.
|
|
848
|
+
- `lintSpace` refuses `whileRunning` on a step that is not the trigger, or an unknown value (`while-running`).
|
|
849
|
+
|
|
850
|
+
## A trigger fired while the page mounts runs its flow
|
|
851
|
+
- A flow starts one microtask after its trigger fires, once the commit that fired it has run all its effects. The
|
|
852
|
+
page's sources (`state`, `navigation`, the actions) register from effects React runs after those of the elements
|
|
853
|
+
under them, so a plugin firing an event from its first effect used to run a flow whose steps found nothing
|
|
854
|
+
registered — and did nothing, silently. `onLoad` and `onPageLoad` had each worked around it on their own.
|
|
855
|
+
- An element unmounted — or mounted again, as React does twice in development — before its flow starts does not run
|
|
856
|
+
it for the subscription that is gone. An element that only re-rendered keeps its subscription: `useInteractions`
|
|
857
|
+
subscribes once per mount and hands new callbacks to `InteractionsManager.update`, so a form marking itself
|
|
858
|
+
submitted as it fires `onSubmit` still runs the flow.
|
|
859
|
+
|
|
860
|
+
## Realtime channels
|
|
861
|
+
- A space declares `channels` — topic patterns (`board:{id}`) with an access rule, who may send (`clients` or only
|
|
862
|
+
the `server`), `presence`, `maxMessageBytes` and `messagesPerSecond`. An undeclared topic is refused by the server,
|
|
863
|
+
by `authorSpace` and by `lintSpace` (`channel-topic`).
|
|
864
|
+
- `sdk-server` serves `/_realtime`: one Server-Sent Events connection per page for every topic it listens to, and a
|
|
865
|
+
`POST` to publish. Every message is authorised, size- and rate-checked, and stamped by the server (`from`, `user`,
|
|
866
|
+
`at`); presence (`$presence`, `$join`, `$leave`) is kept by the connections, nothing stored.
|
|
867
|
+
- Transport is a `PubSubAdapter` the deployment picks: `createServer({ realtime: { pubsub } })`. `createMemoryPubSub`
|
|
868
|
+
(the default, across a server's workers) and `createRedisPubSub({ publisher, subscriber })` ship; anything else is
|
|
869
|
+
one object of two methods. `realtime: false` turns the endpoint off.
|
|
870
|
+
- A server action announces what it did with the `realtime.publish` task (`ctx.publish` for a deployment's own task).
|
|
871
|
+
- On the page: the `channel` element (source `channel_<id>`: `connected`, `me`, `members`, `messages`, `last`;
|
|
872
|
+
`onMessage`, `onJoin`, `onLeave`; `publish`, `setPresence`), and `useChannel` for a plugin that moves at the speed
|
|
873
|
+
of a cursor. `onJoin` is somebody who came after the page, once they announced who they are, and `onLeave` somebody
|
|
874
|
+
who went — both with `from`, `user` and the `state` they announced, so a flow says who (`useChannel`'s `onJoin` /
|
|
875
|
+
`onLeave`, `trackPresence`'s `onArrive` / `onDepart`). Authoring: `channel(...)`, `publishOn`, `announceOn`, and `channels` on the space.
|
|
876
|
+
- A page is one connection and one member per topic, however many elements and plugins listen; a publish waits for
|
|
877
|
+
the connection that includes its topic, so one made right after a navigation is not refused.
|
|
878
|
+
- Two transports at the same address: Server-Sent Events plus a `POST` per publish (`sse`, the default), or one
|
|
879
|
+
WebSocket both ways (`realtime: { transport: 'websocket' }`), where a publish is a frame answered by an `ack`. A
|
|
880
|
+
page falls back to the stream on its own where a socket cannot open (HTTP/2, a proxy that drops upgrades). A socket
|
|
881
|
+
from another origin is refused unless listed in `realtime.allowedOrigins` — CORS does not protect a WebSocket.
|
|
882
|
+
- Upgrades go through the same pipeline as any request (space, auth, then the realtime stage); an upgrade on any other
|
|
883
|
+
path is a `404`. `makeHandler(label, buildContext, stages, { compression, upgrades })` takes an options object.
|
|
884
|
+
- Channel declarations are checked in one place, `channelProblems` (`@plitzi/sdk-shared/realtime`): authoring refuses,
|
|
885
|
+
`lintSpace` reports `channel-declaration`, and the MCP's `patchSettings` takes `channels` (merged per pattern, `null`
|
|
886
|
+
removes one) and answers with the same sentence. The agent's guide has a "Realtime channels" section.
|
|
887
|
+
- Pizarra, a collaborative whiteboard built on all of the above — channels, server actions, a canvas plugin, an agent at
|
|
888
|
+
`/mcp` — is a seeded space on the platform (`pizarra.plitzi.app`) whose server code is its runtime, not an example
|
|
889
|
+
here. See `docs/en/realtime.md`.
|
|
890
|
+
- `lintSpace`'s `channel-topic` skips an element whose `topic` is bound: its topic is only known on the page.
|
|
891
|
+
|
|
892
|
+
## A page on its way out keeps what it showed
|
|
893
|
+
- A server-driven section (`apiContainer` with `runtime: 'server'`, anything reading `useRscData`) on the page being
|
|
894
|
+
left kept rendering its last answer. A navigation asks for the destination's payload before it goes, and the page it
|
|
895
|
+
leaves is still drawn over that payload for a moment — first while it lands, then while the next page renders — with
|
|
896
|
+
no slice for anything on it: every section drew itself empty on the way out (a list turned into its empty state on
|
|
897
|
+
the click that opened one of its items). An element on a layout, which is on every page, is never held back.
|
|
898
|
+
|
|
899
|
+
## Links open where they are asked to
|
|
900
|
+
- A `link` to a page of the site with a `target` of its own (`blank`…) opened in the same tab — the click was always
|
|
901
|
+
taken over for in-place navigation. So was a click held with ⌘, Ctrl or Shift, or with the middle button. Only a
|
|
902
|
+
plain click to the same tab navigates in place now; the rest is the browser's.
|
|
903
|
+
|
|
904
|
+
## `navigation.href`: the page's whole address, from the first paint
|
|
905
|
+
- The navigation store (and the `navigation` global source) carries `href` — origin, path and query — beside
|
|
906
|
+
`origin`, on the server as in the browser. A text or a code built from "this page's link" is right in the first
|
|
907
|
+
paint, where one filled in by the browser once mounted showed its placeholder first. The builder answers it for the
|
|
908
|
+
host being tested.
|
|
909
|
+
|
|
910
|
+
## A server provider pages, and searches, where it is
|
|
911
|
+
- What a refresh of a server-driven provider asks for besides its page — the next page of a "load more", the input it
|
|
912
|
+
was reloaded with — reaches what resolves it. `/_rsc` read only the query of the page's `location` and dropped the
|
|
913
|
+
rest, so a provider paged in place (`pagination: 'append'`, `goToPage`) was answered its first page every time.
|
|
914
|
+
- `performQuery` (authoring: `reloadApi(id, input)`) takes an `input` for a server-driven provider — a search, a
|
|
915
|
+
filter, how many to show — and the provider keeps it for the pages and refreshes after it: "load more" of a search
|
|
916
|
+
is more of the search, and a live refresh does not lose it.
|
|
917
|
+
|
|
918
|
+
## The server names a page's origin with its port
|
|
919
|
+
- The origin a page is rendered with (`navigation.origin`, `location.origin`) lacked the port on the server: a site on
|
|
920
|
+
`:4016` was `http://127.0.0.1` in the first paint and `http://127.0.0.1:4016` once hydrated — a text built from it
|
|
921
|
+
did not hydrate, and a link built from it pointed nowhere until then. It is taken from the request's authority,
|
|
922
|
+
guarded as before against a forged Host.
|
|
923
|
+
|
|
924
|
+
## `onPointerDown`: the press, before it is a click
|
|
925
|
+
- Every element fires `onPointerDown` (authoring: `onPointerDown()`), beside `onClick`, `onHover` and the rest: the
|
|
926
|
+
press itself, where a drag away from the element starts — a tile taken to a canvas, a handle pulled. Let go where it
|
|
927
|
+
went down, it is a click too and `onClick` follows.
|
|
928
|
+
|
|
929
|
+
## `formControl` takes the focus as it appears
|
|
930
|
+
- `autoFocus: true` focuses a control each time it is shown — as it mounts, and again whenever it or anything around
|
|
931
|
+
it goes from hidden to shown, so a search box a shortcut opens is typed into at once however many times it opens.
|
|
932
|
+
Text inputs and textareas alike; never in the builder.
|
|
933
|
+
|
|
934
|
+
## Elements move as they show and hide: the `hidden` style state
|
|
935
|
+
- A class's `states` take `hidden`: how an element looks while its `visible` says no — where it goes as it hides and,
|
|
936
|
+
written as its `@starting-style` too, where it comes from as it shows. With a transition that includes
|
|
937
|
+
`display … allow-discrete`, a panel fades or slides instead of blinking, with a pace of its own each way (the base's
|
|
938
|
+
transition is the way in, the one in `hidden` the way out). `ancestors` take it too, for what is inside something
|
|
939
|
+
that hides. The builder's style editor has it as a tab, like `hover`. The SDK's hidden class is exported as
|
|
940
|
+
`HIDDEN_CLASS` (`@plitzi/sdk-shared/style/styleStates`).
|
|
941
|
+
- `transition-behavior` is in the style vocabulary, and `transition` reads `allow-discrete` into it.
|
|
942
|
+
- A layered `transition`, `animation` or `background` whose layers do not all say the same things expanded the missing
|
|
943
|
+
ones to `initial` — which is not allowed inside a list, so the browser dropped the whole declaration (a second layer
|
|
944
|
+
without a delay voided the first one's). They are now filled with each longhand's initial value, and
|
|
945
|
+
`background-color` is taken from the last layer only.
|
|
946
|
+
|
|
947
|
+
## `formControl` of `subType: 'color'`
|
|
948
|
+
- A form control can be the browser's own colour picker: `subType: 'color'`, its value `#rrggbb`. Its `onChange` fires
|
|
949
|
+
as the colour is picked, like any other control's.
|
|
950
|
+
|
|
951
|
+
## A render reads what a call wrote
|
|
952
|
+
- `createServer` built the actions module for `render` elements on a different config object than the one the
|
|
953
|
+
endpoint used — two modules, so two in-memory `kv` stores and two sets of single-flight guards. With no `kv`
|
|
954
|
+
configured, what a call saved was missing from every render. Both now share one module.
|
|
955
|
+
- The runner, the guards and the module's own `kv` share one default store instead of a Map apiece.
|
|
956
|
+
|
|
957
|
+
## `json_encode` prints JSON for every value
|
|
958
|
+
- `json_encode` and `to_json` encoded objects only: `null` printed as nothing and a string printed bare, so a JSON
|
|
959
|
+
document built around them — a server action's `output`, a `realtime.publish` step's `data` — stopped being JSON the
|
|
960
|
+
moment a value was `null` or text, and the step failed at the end with the work already done. They now do what Twig
|
|
961
|
+
does: a string is quoted and escaped, `null` and an unset value are `null`. `object_as_json` is unchanged.
|
|
962
|
+
|
|
963
|
+
## Keyboard shortcuts leave a text field its own editing
|
|
964
|
+
- With ⌘/Ctrl held a press in a field still reaches `onKey` (so `mod+k` opens a palette from a search box), but the
|
|
965
|
+
field keeps ⌘A, ⌘Z/⌘⇧Z/⌘Y, ⌘C/⌘X/⌘V and moving or deleting by word and line (`isFieldEditing`): a space binding
|
|
966
|
+
`mod+a` to "select all shapes" no longer steals "select this text".
|
|
967
|
+
|
|
968
|
+
## Usable without sight: screen readers and browser agents
|
|
969
|
+
|
|
970
|
+
Screen readers and browser agents (Claude in Chrome) find a page's controls in its accessibility tree. The elements
|
|
971
|
+
now put the right things there, authors can say the rest, and the linter says when they have not. See
|
|
972
|
+
`docs/en/accessibility.md`.
|
|
973
|
+
|
|
974
|
+
- `modalContainer` / `dialogContainer` are a `dialog` / `alertdialog` with `aria-modal`, named by their title. They
|
|
975
|
+
take the focus as they open, keep Tab inside, close on Escape (a dialog is turned down, never accepted) and give the
|
|
976
|
+
focus back as they close. Their footer buttons are `type="button"`.
|
|
977
|
+
- `tabContainer` is a `tablist` of `tab`s (`aria-selected`, `aria-controls`) and `tabpanel`s, with one tab in the Tab
|
|
978
|
+
order; the arrow keys, Home and End move between tabs, Enter and Space select.
|
|
979
|
+
- `formControl`: a control breaking a rule is `aria-invalid` and described by its message (`aria-describedby`), which
|
|
980
|
+
is an `alert`. The password eye is a real button, "Show password", with `aria-pressed`. New `hideLabel`: the label
|
|
981
|
+
stays out of sight and still names the field.
|
|
982
|
+
- `fontAwesome` is `aria-hidden` unless its new `label` gives it a meaning (`role="img"`).
|
|
983
|
+
- `image` takes `decorative`: `alt=""` whatever `alt` says. The builder's settings can now write `alt` at all.
|
|
984
|
+
- `container` takes `label`: a landmark's name, a `section` becomes a region, a `div` a named group
|
|
985
|
+
(`NAMEABLE_CONTAINER_TAGS`).
|
|
986
|
+
- `pagination` is a `nav` named by its new `label` ("Pagination"); the current page is `aria-current="page"`.
|
|
987
|
+
- The segmented `themeToggle` is a named `group` whose options carry `aria-pressed`.
|
|
988
|
+
- `link`'s `label` can be written in the builder. `button` takes a `label` too (`aria-label`), for a button whose words
|
|
989
|
+
do not say what it does — a key hint, a count.
|
|
990
|
+
- `dropdown` marks the control that opens it (`aria-haspopup`, `aria-expanded`); opened from the keyboard the focus
|
|
991
|
+
moves to the popup's first control, and it goes back to the trigger when the popup closes with the focus inside.
|
|
992
|
+
- `container` takes `decorative`: an illustration built from elements, `aria-hidden` whatever it holds.
|
|
993
|
+
- The SDK's button base no longer removes the focus ring for everyone: only for a pointer
|
|
994
|
+
(`:focus:not(:focus-visible)`).
|
|
995
|
+
- New warnings in `lintSpace`: `control-without-name`, `image-without-alt`, `click-on-static-element`,
|
|
996
|
+
`dropdown-without-control`, `heading-level-skipped`, `label-ignored`, `control-in-decorative`. Nothing inside a
|
|
997
|
+
`decorative` container is held to them. The blank space and the examples author with none.
|
|
998
|
+
- `plitzi_screenshot` takes `view: 'accessibility' | 'both'`: the page's accessibility tree as an outline, and every
|
|
999
|
+
control or picture with no name (`unnamed`). `ScreenshotInput.views`, `ScreenshotResult.accessibility`; the HTTP
|
|
1000
|
+
client reads the screenshot service's Puppeteer tree (service ≥ 0.1.9), the local client Playwright's or Puppeteer's.
|
|
1001
|
+
`outlineOfTree`, `outlineOfSnapshot` and `unnamedControls` are exported.
|
|
1002
|
+
- The MCP guide, its quickstart, the server instructions and the co-worker prompt teach it; so does the authoring skill
|
|
1003
|
+
(`reference/accessibility.md`, a recipe, the review checklist).
|
|
1004
|
+
|
|
1005
|
+
## A server with pages open shuts down
|
|
1006
|
+
- `close()` — and so `closeOnSignals` — waited for every open connection to end, and a realtime WebSocket, an event
|
|
1007
|
+
stream or an agent's listening MCP stream never does: with a page open, the first Ctrl+C or SIGTERM hung until a
|
|
1008
|
+
second one, or a SIGKILL, cut it. Now the server stops taking connections, the realtime hub closes its connections
|
|
1009
|
+
(a socket with 1001, going away), any other event stream is ended, requests being answered finish, and what is
|
|
1010
|
+
still open after `SHUTDOWN_GRACE_MS` (10 s) is cut. `HttpServerParts.onClosing`; `RealtimeConnection.end`,
|
|
1011
|
+
`hub.closeAll()`, `hub.connectionCount`.
|
|
1012
|
+
|
|
1013
|
+
## A lighter SDK
|
|
1014
|
+
|
|
1015
|
+
`plitzi-sdk.js` (production) goes from 1141 KB to 766 KB minified, 342 KB to 239 KB gzipped. Nothing a page does
|
|
1016
|
+
changed.
|
|
1017
|
+
|
|
1018
|
+
- No Apollo in the SDK. It sends three queries, always to the network, and carried a GraphQL client with a normalized
|
|
1019
|
+
cache, `graphql`'s parser, rxjs and optimism for them — a quarter of the bundle. A `fetch` sends them now
|
|
1020
|
+
(`createGraphqlClient`, `GraphqlRequestError` with `failure: 'network' | 'http' | 'graphql'`), a 401 still reaches
|
|
1021
|
+
the auth-failure channel, and a page that cannot load says what it said before ("Access not authorized", "Service
|
|
1022
|
+
not available"). `SdkQueries` in `sdk-shared` are strings rather than `gql` documents, typed
|
|
1023
|
+
`Record<keyof SdkQueriesMap, string>`; the builder's documents are unchanged. `@apollo/client` and `graphql` are no
|
|
1024
|
+
longer dependencies of `@plitzi/plitzi-sdk`.
|
|
1025
|
+
- The dev-tools panel is a chunk of its own, `plitzi-sdk-devtools-<hash>.js`, beside `plitzi-sdk.js`: a page loads it
|
|
1026
|
+
when it is allowed to debug and shows the tools, and no other page does. `DevToolsContainer` loads its badge and panel
|
|
1027
|
+
lazily (they were never drawn before hydration), so the builder splits them off too. The chunk imports the SDK as
|
|
1028
|
+
`@plitzi/plitzi-sdk` — the name every page's import map already gives it for plugins — so the panel inspects the
|
|
1029
|
+
page's own stores, not a second copy the `?v=` cache-buster would have loaded. The build fails if anything else ever
|
|
1030
|
+
splits off.
|
|
1031
|
+
- `date-fns-tz` is gone from `sdk-shared`: `formatDateUTC` and `formatUTCToLocal` need no time-zone library.
|
|
1032
|
+
`formatUTCToLocal` printed the hour that repeats when daylight saving time ends an hour off; it no longer does.
|
|
1033
|
+
- The SDK's demo page and the static deployment template map `react/compiler-runtime`, which the bundle imports and
|
|
1034
|
+
only the page server's template mapped: a statically deployed space failed to start with "Failed to resolve module
|
|
1035
|
+
specifier".
|
|
1036
|
+
|
|
1037
|
+
## Two writers at once, and private channels
|
|
1038
|
+
|
|
1039
|
+
What an app with more than one person in it had to build for itself, now the platform's — Pizarra, the whiteboard
|
|
1040
|
+
example, was built on the lack of them and is simpler for it.
|
|
1041
|
+
|
|
1042
|
+
- **`kv.setIf`**: writes only if the key still holds the value the flow read, and answers `written: false` when somebody
|
|
1043
|
+
got there first; empty `expected` = only if nothing is there yet (claim a seat, a username). `ActionKvStore.swap` for
|
|
1044
|
+
a deployment's own tasks.
|
|
1045
|
+
- **Lists**: `list.put` (one entry per id, highest score first, `keep` the top N — answering what it `dropped` — and
|
|
1046
|
+
`higherOnly` to keep a higher score already there), `list.range`, `list.remove`. At most 500 entries and 128 KB.
|
|
1047
|
+
`ActionKvStore.listPut` / `listRange` / `listRemove`.
|
|
1048
|
+
- **`flow.rateLimit`**: at most N runs every so many seconds, per person or for everyone, refused with its message.
|
|
1049
|
+
- **The `kv` adapter has a sixth operation, `swap`** (compare-and-set) — a breaking change for a deployment with its
|
|
1050
|
+
own adapter. Memory, the worker fleet's shared copy, MySQL, Mongo and the examples implement it; **`createRedisKv`**
|
|
1051
|
+
ships in `@plitzi/sdk-server/actions`, so a deployment on Redis writes no adapter at all (plitzi-sdk-server uses it).
|
|
1052
|
+
All of them pass one contract, racing writers included.
|
|
1053
|
+
- **Private channels**: a channel declared `grant: true` opens a topic only for a page that brings a grant for it —
|
|
1054
|
+
handed out by the `realtime.grant` task (or `ctx.grant`) after the flow decided the visitor may be there. Refused
|
|
1055
|
+
otherwise (`ungranted`), however well the topic's name is known. Grants live in the store the actions use, so they
|
|
1056
|
+
work across replicas with nothing new to configure; a day by default, thirty at most. The `channel` element and
|
|
1057
|
+
`useChannel` take a `grant`, the realtime client's `grant(topic, grant)` sends it; `lintSpace` reports a private
|
|
1058
|
+
topic opened with none (`channel-grant`).
|
|
1059
|
+
- **Revoking**: `realtime.revoke { topic, grant }` (or `ctx.revoke`) takes one grant back — or, naming none, every grant
|
|
1060
|
+
for the topic, the ones not used yet too. Whoever is on it with one, on any replica, is let go of it at once: their
|
|
1061
|
+
page hears `$revoked`, the others hear them leave, `useChannel` says `connected: false`; a new grant opens it again.
|
|
1062
|
+
- **MySQL `kv`**: a key is bytes (`VARBINARY(764)`) — `Board` and `board` were one key here and two in Redis and in
|
|
1063
|
+
memory — and a value a `MEDIUMTEXT`, where a `TEXT` refused, or cut short, anything past 64 KB. An existing table is
|
|
1064
|
+
brought up to it on first use, and only when it needs it; `mysqlJobSchemaUpgrades()` for a deployment that migrates
|
|
1065
|
+
its own tables (`createTables: false`).
|
|
1066
|
+
- Pizarra: its gallery is two of these lists — the featured boards and the rest, 200 kept, the one dropped forgotten;
|
|
1067
|
+
every board is written on its own with `swap` instead of one lock for all of them across every replica;
|
|
1068
|
+
its rate limits are `flow.rateLimit` steps in its actions; its board and room channels are private, opened with the
|
|
1069
|
+
grant `board-load`/`board-open` answer — a locked board's topic no longer carries a secret, only its password's
|
|
1070
|
+
version.
|
|
1071
|
+
|
|
1072
|
+
## Functions: a space's own server code
|
|
1073
|
+
|
|
1074
|
+
A space can have its own server code: TypeScript whose **tasks** are steps in its actions and whose
|
|
1075
|
+
**routes** answer under `/api/` on its host, run by the platform in a sandbox. See `docs/en/functions.md`.
|
|
1076
|
+
|
|
1077
|
+
- **The contract**, `@plitzi/sdk-server/functions`: `defineFunctions({ allow: { hosts }, tasks, routes })`, `ctx`
|
|
1078
|
+
(`kv`, `fetch` to declared hosts only — a credential NAMED and written in by the platform, its value never in the code
|
|
1079
|
+
—, `publish`/`grant`/`revoke`, `user` without its session, `log`, `emit`, `signal`), web-standard only.
|
|
1080
|
+
`dist/functions-api.d.ts` is the contract rolled up in one file, for editors (`@plitzi/sdk-server/functions-api.d.ts`).
|
|
1081
|
+
- **Breaking: `action.tasks` is gone.** A deployment's own tasks are functions loaded natively:
|
|
1082
|
+
`createServer({ functions: { native: [defineFunctions({ tasks })] } })` — the same shape a space's are. Their routes
|
|
1083
|
+
are served too.
|
|
1084
|
+
- **`loadFunctions(dir)`** (`@plitzi/sdk-server`): a `functions/` folder built as the platform builds a space's and
|
|
1085
|
+
loaded natively — what a self-hosted server passes to `functions.native`, and what a `plitzi create` server project
|
|
1086
|
+
now does with its own `functions/`. Which files are the source is one rule, `readFunctionsSource` /
|
|
1087
|
+
`isFunctionsSourcePath` in `@plitzi/sdk-shared/actions`, used by it, the build and the CLI.
|
|
1088
|
+
- **The runner**, `@plitzi/sdk-server/functions-runner` (`isolated-vm` and `core-js@3` are optional peers):
|
|
1089
|
+
`startFunctionsRunnerService` — its own process, one V8 isolate per invocation from a snapshot (~2 ms), CPU / wall /
|
|
1090
|
+
memory / output / calls limits that hold, behind a shared secret, warmed before it listens so no request pays the
|
|
1091
|
+
first isolate; `createRemoteRunner` — the page server's client, one WebSocket per invocation with the code's calls
|
|
1092
|
+
answered on it, abandoned past the invocation's wall time plus `graceMs` (5 s) even when the runner never answers;
|
|
1093
|
+
`createIsolateRunner({ concurrency, cacheBytes })` — compiled bundles kept by size (64 MB), least recently used out;
|
|
1094
|
+
`createLocalFunctions`. Isolates need Node started with `--no-node-snapshot` (isolated-vm crashes beside Node's
|
|
1095
|
+
startup snapshot): without it they refuse to start, naming the flag; `plitzi functions dev` re-runs itself with it.
|
|
1096
|
+
- **A run carries the bundle by reference**: `FunctionsBundleRef { id, load }` in `FunctionInvokeRequest` and
|
|
1097
|
+
`SpaceFunctions` — a runner asks for the code (`needBundle`) only when it does not keep that bundle, so a lookup never
|
|
1098
|
+
reads it on the way to one. `functionsInHand` makes one from a bundle already in memory.
|
|
1099
|
+
- **Wired into actions**: `lookups.getFunctions(spaceId, at)`; `registryFor(spaceId, at)` — the catalog, the check and
|
|
1100
|
+
the runs of a space see its tasks; `prepareFunctions(source)` builds, reads and checks a source before it is stored;
|
|
1101
|
+
`functions.limits`, `functions.admit`, `functions.onUsage` for a deployment's ceilings and budget. A run only asks for
|
|
1102
|
+
a space's functions when a step names a task the deployment does not have.
|
|
1103
|
+
- **`ctx.log`** for every task: a step's lines are kept on it (`ActionRunStep.logs`, redacted, at most 100), shown by
|
|
1104
|
+
a Try and in the run history. Builder test runs return `steps`.
|
|
1105
|
+
- **Routes** under `/api/`: the visitor's `cookie`/`authorization` never reach the code, `Set-Cookie` is dropped, a
|
|
1106
|
+
failure answers 500/503 with its reason in the server log only. `lintSpace` refuses a page under `/api`
|
|
1107
|
+
(`page-route-reserved`); the prefix is `FUNCTION_ROUTES_PREFIX` in `@plitzi/sdk-shared/actions`.
|
|
1108
|
+
- **Builder**: a Functions panel — the files, TypeScript that knows `ctx` in a worker of its own
|
|
1109
|
+
(`dist/plitzi-functions-worker.js`, loaded only when the panel opens; the host passes `functionsWorkerUrl`), Save with
|
|
1110
|
+
the problems where they are, what the code declares, and Try. Needs `@plitzi/plitzi-ui` 1.6.24 (`CodeMirror`
|
|
1111
|
+
`mode="ts"` and `extensions`).
|
|
1112
|
+
- **CLI**: `plitzi functions pull | push | try | dev` — `functions/` as a working copy of the space's, refused rather
|
|
1113
|
+
than overwritten in either direction; `dev` runs it on the machine with the project's own `@plitzi/sdk-server`.
|
|
1114
|
+
- **MCP**: the `upsertFunctionFile` / `deleteFunctionFile` operations (saved first in a batch, so a problem refuses
|
|
1115
|
+
it all), `plitzi://functions/{env}` and `/{+path}`, and `plitzi_try_function`.
|
|
1116
|
+
- **`crypto.subtle` derives keys**: PBKDF2 (`importKey('raw', password, 'PBKDF2')`, `deriveBits`, `deriveKey` to an
|
|
1117
|
+
HMAC key) beside digests and HMAC, so a space can keep a password. The runner derives, at most 1,000,000 iterations
|
|
1118
|
+
and 1024 bits a call, and charges what it took to the run's CPU. A key is held to the usages it was imported for.
|
|
1119
|
+
- **`ActionRefusal` from `@plitzi/sdk-server/functions`**: a function refuses with a reason for whoever asked — the
|
|
1120
|
+
page reads it as `{{ step.error }}`, a route answers `400 { error }` — natively and in the sandbox alike (the bundle
|
|
1121
|
+
prints the platform's class; a refusal crosses the runner as `FunctionFailure` reason `refused`). Anything else a
|
|
1122
|
+
function throws still stays in the run's record.
|
|
1123
|
+
- **`ctx.kv.change(key, change, lifetime)`**: read, change and write back, again when somebody wrote first — the loop
|
|
1124
|
+
every concurrent edit needs, now one (lists stand on it too; the sandbox runs the same function). **`ctx.rateLimit`**
|
|
1125
|
+
counts with `flow.rateLimit`'s counter and answers `{ allowed, count, remaining }`. **`ctx.sign` / `ctx.verify`**:
|
|
1126
|
+
HMAC with a per-space, per-environment key derived from the new `action.signingSecret`, which the code never holds.
|
|
1127
|
+
- **Space runtimes**, `@plitzi/sdk-server/runtime` ([docs](../docs/en/runtimes.md)): a space's own server code run as a
|
|
1128
|
+
process of its own beside the platform — `defineRuntime({ start })` answers its `functions` (run with the platform's
|
|
1129
|
+
`ctx` over the runners' protocol) and `endpoints` (web handlers, streamed). `startSpaceRuntime` hosts one,
|
|
1130
|
+
`createRuntimeProxyStage` forwards a space's endpoints to it, `serveRuntime` loads one into a server of its own;
|
|
1131
|
+
`packRuntime` / `inspectRuntime` / `loadRuntime`; `SpaceFunctions.runner` sends a space's tasks to its runtime. One
|
|
1132
|
+
driver for the sandbox and runtimes alike (`createFunctionsDriver`, printed into the guest). CLI: `plitzi runtime
|
|
1133
|
+
push | status | vars`. Builder: a Runtime panel — each environment's state, and write-only variables.
|
|
1134
|
+
`examples/self-hosting/10-runtime` is the smallest one — a task, a route and a stream held open — served by its own
|
|
1135
|
+
`main.ts`; Pizarra, on the platform, is a whole product built this way. A runtime runs at a size — small, medium or large, each a plan feature
|
|
1136
|
+
— shown and chosen per environment in the Runtime panel and with `plitzi runtime size`. One nobody uses for a
|
|
1137
|
+
week — on the platform — stops by itself until started again (Start in the panel, `plitzi runtime start | stop`, a
|
|
1138
|
+
push or a publish) — its status says `starting` or `stopping` meanwhile; the builder's header warns a day before,
|
|
1139
|
+
with a way to keep it running. `createRuntimeProxyStage` takes `onForward`, told of
|
|
1140
|
+
each request it forwards — what counts as a runtime used. `reachSpaceInside` (the host's `insideUrl`)
|
|
1141
|
+
sends a runtime's `fetch` and `WebSocket` to its own space's address to an inside one — a cluster's ingress — instead
|
|
1142
|
+
of out through the edge and back.
|
|
1143
|
+
- **Authoring writes one form of each**: a trigger that `whileRunning('skip', …)` is written as the default it is, and
|
|
1144
|
+
`bind: []` writes no `bindings` — a document read back into code is the one written.
|
|
1145
|
+
- **Shared types**: `FunctionsManifest`, `FunctionsDraft`, `FunctionsProblem`, `FunctionsSaveResult`; the builder's
|
|
1146
|
+
`SpaceFunctions`, `SpaceSaveFunctions`, `SpaceRemoveFunctions`, `SpaceTryFunction`; `ChangeDocument` `functions`
|
|
1147
|
+
with entries of kind `file`; `SSRAdapters.getFunctions` / `saveFunctions` / `tryFunction`.
|
|
1148
|
+
|
|
1149
|
+
- Updated dependencies [cadd1b9]
|
|
1150
|
+
- @plitzi/sdk-shared@0.37.3
|
|
1151
|
+
|
|
3
1152
|
## 0.37.2
|
|
4
1153
|
|
|
5
1154
|
### Patch Changes
|