@plitzi/sdk-server 0.38.8 → 0.38.10

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +6 -1
  3. package/dist/core/createServer.notFound.test.d.ts +1 -0
  4. package/dist/core/oauth/consentPage.js +1 -9
  5. package/dist/core/unusedPort.d.ts +6 -0
  6. package/dist/core/unusedPort.js +20 -0
  7. package/dist/helpers/compress.js +1 -1
  8. package/dist/helpers/escapeHtml.d.ts +2 -0
  9. package/dist/helpers/escapeHtml.js +13 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +2 -1
  12. package/dist/modules/actions/runtime/errors.d.ts +6 -0
  13. package/dist/modules/actions/runtime/errors.js +7 -1
  14. package/dist/modules/actions/runtime/runAction.js +17 -3
  15. package/dist/modules/functions/manifest.js +6 -1
  16. package/dist/modules/functions/routes.js +2 -2
  17. package/dist/modules/project/serveProject.d.ts +5 -2
  18. package/dist/modules/project/serveProject.js +42 -14
  19. package/dist/modules/project/spaceFailure.d.ts +8 -0
  20. package/dist/modules/project/spaceFailure.js +76 -0
  21. package/dist/modules/runtime/runner.d.ts +1 -0
  22. package/dist/modules/runtime/runner.js +2 -3
  23. package/dist/modules/ssr/applySSRResult.d.ts +11 -0
  24. package/dist/modules/ssr/applySSRResult.js +14 -1
  25. package/dist/modules/ssr/buildBody.js +2 -0
  26. package/dist/modules/ssr/handler.js +2 -2
  27. package/dist/modules/ssr/prepareRender.d.ts +2 -0
  28. package/dist/modules/ssr/prepareRender.js +12 -4
  29. package/dist/modules/ssr/resolvePageSeo.d.ts +4 -1
  30. package/dist/modules/ssr/resolvePageSeo.js +7 -3
  31. package/dist/modules/ssr/streamBody.js +3 -2
  32. package/package.json +3 -3
  33. package/skills/plitzi-authoring/CHEATSHEET.md +1 -1
  34. package/skills/plitzi-authoring/recipes/detail-page.ts +4 -2
  35. package/skills/plitzi-authoring/recipes/filter-a-list.ts +0 -2
  36. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +2 -1
  37. package/skills/plitzi-authoring/reference/accessibility.md +2 -0
  38. package/skills/plitzi-authoring/reference/authoring-errors.md +6 -0
  39. package/skills/plitzi-authoring/reference/flows.md +2 -2
  40. package/skills/plitzi-authoring/reference/layouts.md +20 -0
  41. package/skills/plitzi-authoring/reference/recipes.md +1 -1
  42. package/skills/plitzi-authoring/reference/structure.md +3 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,154 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.38.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 200da2f: - **`focus-on-field-box`** (`@plitzi/sdk-authoring`): a warning for a `focus` or `focus-visible` state on a text
8
+ field's or a select's `input` slot. That slot is the box the field is drawn in, a `<div>` that never takes focus, so
9
+ the rule was never seen and a keyboard user tabbing to the field found no ring. The fix it names: `'focus-within'` on
10
+ the same class, or the state on the `field` slot.
11
+ - **`modalContainer` says how it is laid out** (`@plitzi/sdk-elements`): its description now names its slots and how
12
+ they sit — the dim layer and the dialog side by side, not one inside the other; the dialog centred by `top`/`left`
13
+ 50% and a `translate`. A space styled the dim layer as a flex container to place the dialog, which moved nothing.
14
+ - **A field shows where the keyboard is** (`@plitzi/plitzi-sdk`, `@plitzi/sdk-elements`): a text field's or a select's
15
+ box is ringed (`2px solid currentColor`) while the field inside has keyboard focus, and a textarea while it has. No
16
+ text field or select showed any focus at all: the box had `outline: none`, and the field inherited it. One class of
17
+ specificity, so whatever a space writes about the outline wins; never in the builder's canvas, where the selection is
18
+ the ring. The field no longer inherits the box's outline — the box draws it.
19
+ - **The dim layer of a modal and a dialog is `rgb(0 0 0 / 50%)`** (`@plitzi/plitzi-sdk`, `@plitzi/sdk-elements`), not
20
+ black at `opacity: 0.5`: the default looks the same, and a colour a space gives the layer is drawn as it is — it was
21
+ halved. A space that set its own colour sees it darker: the one it wrote.
22
+ - **`required-message-unused`** (`@plitzi/sdk-authoring`): a warning for a `requiredMessage` on a field nothing requires —
23
+ since 0.38.9 a field is optional unless `required: true`, so the message is never shown and an empty answer is sent.
24
+ The formControl's description, the cheat sheet and the forms recipe say the default.
25
+ - **A page's title can say its record** (`@plitzi/sdk-shared`, `@plitzi/sdk-server`, `@plitzi/sdk-elements`,
26
+ `@plitzi/sdk-authoring`): `seoTitle` and `seoDescription` may be templates over the page's `runtime: 'server'`
27
+ providers (its layouts' too) and `navigation` — `'{{ apiContainer_post.title }} — Blog'`. The server evaluates them
28
+ as it writes the head, from the answers it already has; the browser evaluates the same over `rsc.data`, so the tab
29
+ follows a navigation from record to record. One that reads anything else — a browser provider, `state` — is refused
30
+ (`page-template`): it was written into the head as it was, braces included. A declaration's `serverTemplates` names
31
+ the attributes the page server evaluates against a context of its own (a page's title and description, a provider's
32
+ `notFound`): the element runtime leaves them as written — interpolated where the page renders, a `|default(…)`
33
+ resolved to its fallback before the head could read the providers — and the authoring lint reads the same list.
34
+ - **A page says when its address shows nothing** (`@plitzi/sdk-server`, `@plitzi/sdk-authoring`): `notFound` on the
35
+ page, one expression over the same answers its title reads — `"{{ not (apiContainer_feed.topics|find('slug',
36
+ navigation.routeParams.slug)) }}"` — sends it with status 404, rendered as written. A provider's own `notFound` reads
37
+ only its answer, and one in a layout is every page's: a record read from the layout had no way to say it was not
38
+ there, and the page answered 200.
39
+ - **The Export keeps the flows of a plugin hosted by `custom`** (`@plitzi/sdk-authoring`): `specFromSpace` judged such
40
+ an element by `custom`'s own triggers and callbacks, and dropped a flow on the component's event (`onChange` on an
41
+ editor) as one "a custom never fires" — the space read back was not the space written.
42
+ - **`page check` reads every word on the page, in the colour the browser paints it** (`@plitzi/sdk-authoring`): what a
43
+ plugin draws inside an element of the space is measured too, named by the element and a selector; the measure is
44
+ WCAG's contrast (under 2:1 is unreadable), not "nearly the same colour"; and a colour the browser answers as
45
+ `oklch()`, `oklab()` or `color(srgb …)` — every token and every `color-mix()` — is read as that, where it was read as
46
+ `rgb()` numbers and came out near black. A plugin's dark-on-dark status line passed as "nothing wrong".
47
+ - **`verify` checks every theme the space can be painted in** (`@plitzi/cli`): light and dark, unless the space says
48
+ light only — "48 checks at 1440 and 390 px, light and dark". A page unreadable in the dark theme passed in the light
49
+ one; a failure names the theme it was found in.
50
+ - **`plitzi upgrade` says where fields became optional** (`@plitzi/cli`): for a project last upgraded before 0.38.9,
51
+ each `formControl(…)` with no `required`, at its file and line — said, never written. And a `.gitkeep` is asked for
52
+ only in a folder with nothing else in it: `upgrade` wrote one beside code, and `doctor` called it missing once deleted
53
+ as AGENTS.md asks ("nothing unused") — the three now say the same.
54
+ - **`devMode` and `devReload` are the project's** (`@plitzi/sdk-server`): `src/config/serverOptions.ts` may set them,
55
+ over `NODE_ENV`, which they still follow when left out. A deployment started without `NODE_ENV` (a container's `CMD`)
56
+ says `devMode: false`, and a public action answers with its output alone.
57
+ - **`page shot --from load`** (`@plitzi/cli`): `--frames` start as soon as the page's HTML is in, not once it settles —
58
+ an entrance that plays while the page loads had ended before the first picture.
59
+ - **`heavy-animation` says where each animation is** (`@plitzi/sdk-authoring`): the class and its variant, state or
60
+ slot — or the `customCss` rule — and writes the fix against that selector. It said "in `customCss`" for an animation
61
+ declared on a class's variant, and offered `.glow`, a class no space has.
62
+ - **`page shot --steps`** (`@plitzi/cli`): an interaction played step by step — `click`, `type`, `press`, `wait`,
63
+ `wait-for`, `shot [label]`, `frames <n> [ms]`, separated by `;` or written in a file (`@file`) — each picture labelled
64
+ with its step and the time since the first, and said when nothing changed since the one before. A queue filling, a
65
+ restart, an opening frame by frame took a Playwright script of the project's own. Targets take Playwright's selectors
66
+ too (`text=Saved`).
67
+ - **A space's task may take a `number` param** (`@plitzi/sdk-server`): saved functions refused it ("a type the builder
68
+ cannot draw") while a project's server ran it, so code that worked self-hosted failed on the platform — and the
69
+ builder draws one, a callback's. A step's params now arrive as the types the task declares, on every server: a
70
+ `number` written as text is the number, a `boolean` written `'true'` is `true`, as the browser reads a callback's.
71
+ - **`page shot --as <username>`** (`@plitzi/cli`): signs in first, as `page check --as` does, the password from
72
+ `PLITZI_CHECK_PASSWORD` in `.env`. A page for signed-in visitors was pictured as the sign-in page it sent the browser
73
+ to, without a word; `page shot` now says when the page sent it elsewhere, and that the picture is of that page.
74
+ - **A capture is of the page at rest** (`@plitzi/sdk-mcp`): the local screenshot client takes every picture with
75
+ `prefers-reduced-motion: reduce`, as the browser pod now does — the declared motion and a space's own keyframes that
76
+ honour the query are shown finished. A splash still covering its page, or an arrival caught halfway, was what a
77
+ thumbnail or a `plitzi_look` showed of a page that animates as it loads.
78
+ - **The server renders in the visitor's theme, not only the class** (`@plitzi/sdk-shared`): `ThemeProvider` set the
79
+ starting theme from a layout effect, which never runs on a server, so a page was drawn from the theme store's own
80
+ start — `{{ theme.resolved }}` was `light` under a `<html class="dark">`, and whatever named the scheme (a picture's
81
+ address, a `when` rule) was swapped for the other one as the page hydrated. The theme a host supplies (the cookie) is
82
+ now the server's snapshot of the store, and the hydration's; the shared store is never written on the server.
83
+ - Updated dependencies [200da2f]
84
+ - @plitzi/plitzi-sdk@0.38.10
85
+ - @plitzi/sdk-shared@0.38.10
86
+
87
+ ## 0.38.9
88
+
89
+ ### Patch Changes
90
+
91
+ - 64f4cd6: - **`unusedPort()`** (`@plitzi/sdk-server`): a port the system has just handed out, for a test to listen on. The
92
+ package's own tests and `@plitzi/sdk-mcp`'s listened on fixed ports in the ephemeral range (393xx), which any
93
+ outgoing connection of a test running beside them could take: CI failed with `EADDRINUSE` on runs with nothing
94
+ wrong. Every one now listens on a port the system gives it.
95
+ - **Brotli at quality 5** (`@plitzi/sdk-server`): `compression.brotliQuality`, for a page rendered for one request, is
96
+ 5 (was 2). At 2 a 220 KB page came out larger than gzip — 36 KB against 31 — and at 5 it is 29 KB, for the CPU gzip
97
+ spends; on half a core ~0.1 ms a page.
98
+ - **`verify` checks the pages for signed-in visitors** (`@plitzi/cli`): a page that sends the browser to sign in is
99
+ checked again signed in as the account `.env` names — `PLITZI_CHECK_USER`, `PLITZI_CHECK_PASSWORD` (the environment
100
+ wins over `.env`). With none named, `verify` says how. `page check --as` reads the password from `.env` too, and
101
+ `page check` takes several pages, in one browser.
102
+ - **An address that shows nothing answers 404** (`@plitzi/sdk-shared`, `@plitzi/plitzi-sdk`, `@plitzi/sdk-server`):
103
+ one no page answers was sent to the home page with a 302 — every typo read as the home page. A page whose slug is
104
+ `'*'` is now the space's page for it, sent with status 404 (a folder may have its own; the deepest wins), and a page
105
+ gated off by a flag shows it too. Never kept in the page cache: an address asked for at random filled it. Only a slug
106
+ that IS `'*'`: a page answering a subtree by its own (`spaces/:spaceId/update/*`) stays a page like any other.
107
+ - **`authorSpace` gives a space with no `'*'` page a plain one** (`@plitzi/sdk-authoring`), in the home page's layout,
108
+ and says so: suggestion `not-found-page`. The project templates carry their own.
109
+ - **`notFound` on a server provider** (`@plitzi/sdk-elements`, `@plitzi/sdk-server`): an expression against its answer
110
+ (`{{ source.found == false }}`); when it is true the page is sent with status 404, rendered as written. Refused on a
111
+ browser provider (`not-found-in-browser`) and when it is not one expression (`not-found-not-a-template`).
112
+ - **`page check` and `verify` know a page sent with 404** (`@plitzi/cli`): it is said, not failed; the space's `'*'`
113
+ page is checked at an address no page has, and fails if it is not sent with 404.
114
+ - **A form field draws no background of its own** (`@plitzi/sdk-elements`): the `<input>` inside a form control's box
115
+ (`field`) took `background-color: inherit`, and painted a translucent box colour a second time — a darker box inside
116
+ the padding (Tremor's search). It is `transparent`: the box shows through it.
117
+ - **`space lint` offers `quiet` only where there is an element to put it on** (`@plitzi/cli`): a suggestion about what
118
+ the space lacks — `not-found-page` — is answered by writing it.
119
+ - **`feedback --previous`** (`@plitzi/cli`): the brief says what to do when an earlier report's link no longer opens —
120
+ say so, and ask the person for the highest id it had.
121
+ - **`start:prod` runs in production by itself** (`@plitzi/cli`): `NODE_ENV=production node …`. Without the variable a
122
+ deployed project was a development server — dev tools on, and every public action answering its `steps` and `trace`
123
+ (each step's result, what the `output` was meant to leave out). `doctor` says a `start:prod` that does not
124
+ (`start-prod-not-production`); `plitzi upgrade --write` rewrites the script.
125
+ - **A project's `ActionRefusal` reaches the flow** (`@plitzi/sdk-server`): a functions bundle carries its own copy of the
126
+ class, so `instanceof` never knew it and the run lost its reason (`{{ x.error }}` empty). Told apart by name now.
127
+ - **Plugins are not remounted after hydration** (`@plitzi/sdk-elements`): the registry wrapped each plugin again when
128
+ the host handed it over again, a new component type every time — the server's HTML thrown away, entrances replayed,
129
+ effects run twice. Each plugin component is wrapped once.
130
+ - **`start:dev` survives a space that does not author** (`@plitzi/sdk-server`): a restart while the space had an error
131
+ died, and fixing the space woke nothing. It comes up answering every page with what is wrong (503), and loads the
132
+ space the first time a save authors.
133
+ - **`page check` sees what is cut off at the screen's edge** (`@plitzi/sdk-authoring`, `@plitzi/cli`): a link or words an
134
+ ancestor hides past the viewport, which no sideways scroll shows (`cut-off`).
135
+ - **`page shot --click <element...> --sheet`** (`@plitzi/cli`): elements clicked in order — a name or a CSS selector,
136
+ inside a plugin too — pictured after each (`--frames`/`--every`), with how much changed, and every picture on one
137
+ contact sheet. The dev tools' badge is no longer in a picture: its shadow root is marked (`@plitzi/sdk-dev-tools`).
138
+ - **`formControl` is optional unless it says `required: true`** (`@plitzi/sdk-elements`), as an HTML field is. A field
139
+ left without `required` in an older space was required, and is not now.
140
+ - **`text({ decorative })`, `list({ label })`** (`@plitzi/sdk-elements`); **`writing-mode`, `text-orientation`** in the
141
+ CSS vocabulary (`@plitzi/sdk-shared`).
142
+ - **`unused-token` reads the plugins' stylesheets** (`@plitzi/sdk-authoring`): `authorSpace`'s `stylesheets`, which
143
+ `projectAuthoring` fills with every `.css` under `src/plugins/` — a token only a plugin used was said to be unused.
144
+ - **`id-taken` on a provider placed on two pages points at the layout they share** (`@plitzi/sdk-authoring`), not at
145
+ `scope()`, which renamed its source and every template reading it.
146
+ - **The welcome template's tokens keep their names** (`@plitzi/sdk-authoring`): `satisfies`, not an annotation, so
147
+ `tokens(variables).card` typechecks as the cheatsheet teaches.
148
+ - Updated dependencies [64f4cd6]
149
+ - @plitzi/sdk-shared@0.38.9
150
+ - @plitzi/plitzi-sdk@0.38.9
151
+
3
152
  ## 0.38.8
4
153
 
5
154
  ### Patch Changes
package/README.md CHANGED
@@ -400,6 +400,11 @@ Beside the page adapters it offers `getSchema` and `getStyle`, read from the sam
400
400
  path, or a function that may answer one — `saveSchema` and `saveStyle`, each writing its document back into the file. So an MCP server
401
401
  ([`@plitzi/sdk-mcp`](../mcp/README.md#adapters)) can run over it, with a `getGrant` of yours beside it.
402
402
 
403
+ In a test, listen on `await unusedPort()`: a port the system has just handed out, never a fixed number — one in the
404
+ system's ephemeral range can be taken by any outgoing connection of a test running beside it, and the listen fails with
405
+ `EADDRINUSE` on a run with nothing wrong. (`freePort(8080)` is the development server's: the next free one from a port
406
+ people expect.)
407
+
403
408
  ### `JsonAdaptersConfig`
404
409
 
405
410
  | Option | Type | Description |
@@ -573,7 +578,7 @@ createServer({ compression: false, adapters });
573
578
  |---|---|---|---|
574
579
  | `encodings` | `('br' \| 'gzip')[]` | `['br', 'gzip']` | What this server offers, most preferred first; the first one the client accepts wins. `[]` disables compression. |
575
580
  | `threshold` | `number` | `1024` | Responses smaller than this many bytes go out uncompressed. |
576
- | `brotliQuality` | `number` | `2` | Brotli quality, 0–11, for a body compressed on every request (a page rendered for that request alone). Measured on a quarter core, 4 cost a tenth of the pages a second to save half a kilobyte each. |
581
+ | `brotliQuality` | `number` | `5` | Brotli quality, 0–11, for a body compressed on every request (a page rendered for that request alone). The first quality under gzip's size for the CPU gzip spends: at 2 a 220 KB page came out larger than gzip (36 KB against 31; 29 at 5). On half a core it costs ~0.1 ms a page — 5–8% of the pages a second of a 6 KB page. |
577
582
  | `keptBrotliQuality` | `number` | `6` | Brotli quality for a body compressed once and kept: a cached page, a static file such as the SDK bundle. Paid once; the bundle comes out 10% smaller than at 4, for the same memory. 9 needs ~40 MB more to compress the bundle, which a 128 MB server does not have. |
578
583
  | `gzipLevel` | `number` | `6` | Gzip level, 0–9. |
579
584
 
@@ -0,0 +1 @@
1
+ export {};
@@ -1,13 +1,5 @@
1
+ import { escapeHtml } from "../../helpers/escapeHtml.js";
1
2
  //#region src/core/oauth/consentPage.ts
2
- var escapeHtml = (value) => value.replace(/[&<>"']/gu, (character) => {
3
- switch (character) {
4
- case "&": return "&amp;";
5
- case "<": return "&lt;";
6
- case ">": return "&gt;";
7
- case "\"": return "&quot;";
8
- default: return "&#39;";
9
- }
10
- });
11
3
  var STYLES = `
12
4
  :root { color-scheme: light dark; --bg: #f6f7f9; --panel: #ffffff; --ink: #16181d; --muted: #6b7280;
13
5
  --line: #e2e5ea; --accent: #2563eb; --danger: #b91c1c; }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A port the system has just handed out and nobody holds — what a test listens on. Never a fixed number: one in the
3
+ * system's ephemeral range (above 32768 on Linux) can be taken by any outgoing connection of a test running beside it,
4
+ * and the listen fails with `EADDRINUSE` on a run that has nothing wrong with it.
5
+ */
6
+ export declare const unusedPort: (host?: string) => Promise<number>;
@@ -0,0 +1,20 @@
1
+ import { createServer } from "node:net";
2
+ //#region src/core/unusedPort.ts
3
+ /**
4
+ * A port the system has just handed out and nobody holds — what a test listens on. Never a fixed number: one in the
5
+ * system's ephemeral range (above 32768 on Linux) can be taken by any outgoing connection of a test running beside it,
6
+ * and the listen fails with `EADDRINUSE` on a run that has nothing wrong with it.
7
+ */
8
+ var unusedPort = (host = "127.0.0.1") => new Promise((resolve, reject) => {
9
+ const probe = createServer();
10
+ probe.once("error", reject);
11
+ probe.listen(0, host, () => {
12
+ const address = probe.address();
13
+ probe.close(() => {
14
+ if (typeof address === "object" && address) resolve(address.port);
15
+ else reject(/* @__PURE__ */ new Error(`The system gave no port on ${host}`));
16
+ });
17
+ });
18
+ });
19
+ //#endregion
20
+ export { unusedPort };
@@ -4,7 +4,7 @@ import { brotliCompressSync, constants, gzipSync } from "node:zlib";
4
4
  var DEFAULT_COMPRESSION = {
5
5
  encodings: ["br", "gzip"],
6
6
  threshold: 1024,
7
- brotliQuality: 2,
7
+ brotliQuality: 5,
8
8
  keptBrotliQuality: 6,
9
9
  gzipLevel: 6
10
10
  };
@@ -0,0 +1,2 @@
1
+ /** Text set inside HTML as text: never markup, whatever it holds. */
2
+ export declare const escapeHtml: (value: string) => string;
@@ -0,0 +1,13 @@
1
+ //#region src/helpers/escapeHtml.ts
2
+ /** Text set inside HTML as text: never markup, whatever it holds. */
3
+ var escapeHtml = (value) => value.replace(/[&<>"']/gu, (character) => {
4
+ switch (character) {
5
+ case "&": return "&amp;";
6
+ case "<": return "&lt;";
7
+ case ">": return "&gt;";
8
+ case "\"": return "&quot;";
9
+ default: return "&#39;";
10
+ }
11
+ });
12
+ //#endregion
13
+ export { escapeHtml };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { createServer, resolveServices } from './core/createServer';
2
2
  export { closeOnSignals } from './core/server/closeOnSignals';
3
3
  export { loadFunctions, loadFunctionsSource, readFunctionsDir } from './modules/functions/load';
4
4
  export { freePort } from './core/freePort';
5
+ export { unusedPort } from './core/unusedPort';
5
6
  export { registerHealthCheck, buildHealthPayload } from './core/health';
6
7
  export { consoleLogger, isLogged, logLevelOf, renderLogEvent, serverLog } from './helpers/serverLog';
7
8
  export type { HealthCheckApp, HealthIdentity } from './core/health';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { buildHealthPayload, registerHealthCheck } from "./core/health.js";
11
11
  import { createServer } from "./core/createServer.js";
12
12
  import { closeOnSignals } from "./core/server/closeOnSignals.js";
13
13
  import { freePort } from "./core/freePort.js";
14
+ import { unusedPort } from "./core/unusedPort.js";
14
15
  import { createCloudAdapters } from "./adapters/cloudAdapters.js";
15
16
  import { createJsonAdapters } from "./adapters/jsonAdapters.js";
16
17
  import { createAuthAdapters } from "./adapters/authAdapters.js";
@@ -19,4 +20,4 @@ import { authoringPreview, fixedSpace, presentsCredential, verifiedDomain, wildc
19
20
  import { createSpaceAdapters } from "./adapters/space/index.js";
20
21
  import { createLocalFontStore, hostedPathsOf } from "./core/fontStore.js";
21
22
  import { createRedisPubSub } from "./modules/realtime/redisPubSub.js";
22
- export { PREVIEW_TOKEN_PARAM, authoringPreview, buildHealthPayload, closeOnSignals, consoleLogger, createAuthAdapters, createCloudAdapters, createConnectorResolver, createJsonAdapters, createLocalFontStore, createMemoryCache, createMemoryDraftStore, createMemoryPubSub, createRedisPubSub, createServer, createSpaceAdapters, fetchConnectorRecords, fixedSpace, freePort, hostedPathsOf, isLogged, loadFunctions, loadFunctionsSource, logLevelOf, presentsCredential, readFunctionsDir, rebaseMedia, refuse, registerHealthCheck, renderLogEvent, resolveRscData, resolveServices, serverLog, takeDraftOverride, verifiedDomain, wildcardSubdomain, writeConnectorRecord };
23
+ export { PREVIEW_TOKEN_PARAM, authoringPreview, buildHealthPayload, closeOnSignals, consoleLogger, createAuthAdapters, createCloudAdapters, createConnectorResolver, createJsonAdapters, createLocalFontStore, createMemoryCache, createMemoryDraftStore, createMemoryPubSub, createRedisPubSub, createServer, createSpaceAdapters, fetchConnectorRecords, fixedSpace, freePort, hostedPathsOf, isLogged, loadFunctions, loadFunctionsSource, logLevelOf, presentsCredential, readFunctionsDir, rebaseMedia, refuse, registerHealthCheck, renderLogEvent, resolveRscData, resolveServices, serverLog, takeDraftOverride, unusedPort, verifiedDomain, wildcardSubdomain, writeConnectorRecord };
@@ -26,3 +26,9 @@ export declare class ActionRunError extends Error {
26
26
  export declare class ActionRefusal extends Error {
27
27
  constructor(message: string);
28
28
  }
29
+ /**
30
+ * Whether an error is a refusal — told apart by its NAME, never by `instanceof`: a functions bundle carries its own copy
31
+ * of the class (`functions/build.ts`), so a project's code loaded from one throws a refusal this class never made, and
32
+ * its reason was dropped on the way to the page.
33
+ */
34
+ export declare const isActionRefusal: (error: unknown) => error is Error;
@@ -33,5 +33,11 @@ var ActionRefusal = class extends Error {
33
33
  this.name = "ActionRefusal";
34
34
  }
35
35
  };
36
+ /**
37
+ * Whether an error is a refusal — told apart by its NAME, never by `instanceof`: a functions bundle carries its own copy
38
+ * of the class (`functions/build.ts`), so a project's code loaded from one throws a refusal this class never made, and
39
+ * its reason was dropped on the way to the page.
40
+ */
41
+ var isActionRefusal = (error) => error instanceof Error && error.name === "ActionRefusal";
36
42
  //#endregion
37
- export { ActionRefusal, ActionRunError };
43
+ export { ActionRefusal, ActionRunError, isActionRefusal };
@@ -1,5 +1,5 @@
1
1
  import { serverLog } from "../../../helpers/serverLog.js";
2
- import { ActionRefusal, ActionRunError } from "./errors.js";
2
+ import { ActionRunError, isActionRefusal } from "./errors.js";
3
3
  import { runCancelKey } from "./guards.js";
4
4
  import { createKvStore } from "./kvStore.js";
5
5
  import { resolveLimits } from "./limits.js";
@@ -148,8 +148,22 @@ var stepOf = (node, { status, phase, startTime, endTime, error, logs = [] }) =>
148
148
  /** What one step may log: enough to debug with, bounded so a loop that logs cannot fill a run's record. */
149
149
  var MAX_LOG_LINES = 100;
150
150
  var MAX_LOG_LINE = 1e3;
151
+ /**
152
+ * A value written as text, read as the type its task declares: a `number` param written `5` — or bound to text that says
153
+ * it — arrives as 5, a `boolean` one written `'true'` as `true`, an empty number as nothing (so the default applies). The
154
+ * browser reads a callback's params the same way (`coerceDeclaredParams`): a task's author never writes `Number(…)`.
155
+ */
156
+ var asDeclared = (type, value) => {
157
+ if (typeof value !== "string") return value;
158
+ if (type === "number") {
159
+ const parsed = Number(value.trim());
160
+ return value.trim() === "" ? void 0 : Number.isFinite(parsed) ? parsed : value;
161
+ }
162
+ return type === "boolean" && (value === "true" || value === "false") ? value === "true" : value;
163
+ };
151
164
  var withDefaults = (task, params) => Object.entries(task.params).reduce((acum, [key, param]) => {
152
- if (acum[key] === void 0 || acum[key] === "") acum[key] = param.defaultValue ?? "";
165
+ const value = asDeclared(typeof param.type === "function" ? param.type(acum) : param.type, acum[key]);
166
+ acum[key] = value === void 0 || value === "" ? param.defaultValue ?? "" : value;
153
167
  return acum;
154
168
  }, { ...params });
155
169
  var runNode = async (node, scope, registry, buildContext) => {
@@ -464,7 +478,7 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
464
478
  });
465
479
  } else {
466
480
  failure = error instanceof Error ? error.message : String(error);
467
- if (error instanceof ActionRefusal) refusal = redact(error.message);
481
+ if (isActionRefusal(error)) refusal = redact(error.message);
468
482
  trace.push({
469
483
  node: errorNode("error"),
470
484
  status: "failed",
@@ -57,6 +57,11 @@ var paramOf = (value) => {
57
57
  type: value.type,
58
58
  ...typeof defaultValue === "string" || typeof defaultValue === "number" ? { defaultValue } : {}
59
59
  };
60
+ case "number": return {
61
+ ...base,
62
+ type: "number",
63
+ ...typeof defaultValue === "number" ? { defaultValue } : {}
64
+ };
60
65
  case "codemirror-text":
61
66
  case "codemirror-json": return {
62
67
  ...base,
@@ -84,7 +89,7 @@ var paramOf = (value) => {
84
89
  ...typeof defaultValue === "string" ? { defaultValue } : {}
85
90
  };
86
91
  }
87
- default: return `has a type the builder cannot draw ("${String(value.type)}"): text, textarea, codemirror-text, codemirror-json, boolean, elements or select`;
92
+ default: return `has a type the builder cannot draw ("${String(value.type)}"): text, number, textarea, codemirror-text, codemirror-json, boolean, elements or select`;
88
93
  }
89
94
  };
90
95
  /** What each asked limit is, in words a problem is written in. */
@@ -1,5 +1,5 @@
1
1
  import { serverLog } from "../../helpers/serverLog.js";
2
- import { ActionRefusal } from "../actions/runtime/errors.js";
2
+ import { isActionRefusal } from "../actions/runtime/errors.js";
3
3
  import { pluginOfPath } from "./scope.js";
4
4
  import { functionContextFor } from "./context.js";
5
5
  import { functionLimitsFor } from "./config.js";
@@ -69,7 +69,7 @@ var withoutCookies = (response) => {
69
69
  * of anything else the visitor gets that it failed, and the server's log gets why.
70
70
  */
71
71
  var failed = (visit, key, error) => {
72
- if (error instanceof ActionRefusal || error instanceof FunctionFailure && error.reason === "refused") return Response.json({ error: error.message }, { status: 400 });
72
+ if (isActionRefusal(error) || error instanceof FunctionFailure && error.reason === "refused") return Response.json({ error: error.message }, { status: 400 });
73
73
  serverLog.error("Functions", `route ${key} of space ${String(visit.spaceId)} failed`, error);
74
74
  const stopped = error instanceof FunctionFailure && error.reason !== "error";
75
75
  return Response.json({ error: stopped ? "This route ran out of what it may spend" : "This route failed" }, { status: stopped ? 503 : 500 });
@@ -3,11 +3,14 @@ import { ServerConfig } from '../../core/createServer';
3
3
  import { ConnectorManifest } from '../connectors';
4
4
  import { ActionEntry, SSRServer } from '@plitzi/sdk-shared';
5
5
  /** What `serveProject` sets itself, from the project's layout — so never the project's options'. */
6
- type SetByProject = 'port' | 'devMode' | 'devReload' | 'health' | 'adapters' | 'plugins' | 'publicDir' | 'dataDir' | 'functions' | 'action';
6
+ type SetByProject = 'port' | 'health' | 'adapters' | 'plugins' | 'publicDir' | 'dataDir' | 'functions' | 'action';
7
7
  /**
8
8
  * What a project's server does besides serving its space — its `src/config/serverOptions.ts`: what `createServer`
9
9
  * takes, but for what `serveProject` wires from the project's layout, and an action's `lookups`, which are the
10
- * project's `src/actions/`. `logLevel` and `logger` are the project's to change; `action.kv` too.
10
+ * project's `src/actions/`. `logLevel` and `logger` are the project's to change; `action.kv` too. So are `devMode` and
11
+ * `devReload`, which follow `NODE_ENV` when left out — on unless it is `production`. A deployment started without it (a
12
+ * container's `CMD`, a process manager with no environment) says `devMode: false` here, and a public action answers
13
+ * with its output alone, never the trace of every step.
11
14
  */
12
15
  export type ProjectServerOptions = Partial<Omit<ServerConfig, SetByProject>> & {
13
16
  action?: Omit<NonNullable<ServerConfig['action']>, 'lookups'>;
@@ -10,12 +10,15 @@ import { createFileKv } from "../actions/runtime/fileKv.js";
10
10
  import { loadRuntime, loadRuntimeModule } from "../runtime/bundle.js";
11
11
  import { serveRuntime } from "../runtime/stages.js";
12
12
  import { projectPlugins, watchProjectPlugins } from "./plugins.js";
13
+ import { createSpaceFailure } from "./spaceFailure.js";
13
14
  import { watchSpace } from "./watchSpace.js";
14
15
  import path from "node:path";
15
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
17
  import { assertProjectLayout, layoutFindingText } from "@plitzi/sdk-shared/project/layout";
17
18
  import { DATA_DIR, DEV_SERVER_FILE, FUNCTIONS_DIR, KV_FILE, PROJECT_TMP, PUBLIC_DIR, RUNTIME_BUNDLE, RUNTIME_ENTRY } from "@plitzi/sdk-shared/project/paths";
18
19
  import { projectModule, projectRoot } from "@plitzi/sdk-shared/project/root";
20
+ import { EMPTY_SCHEMA } from "@plitzi/sdk-shared/schema/schemaConstants";
21
+ import { EMPTY_STYLE_SCHEMA } from "@plitzi/sdk-shared/style/styleConstants";
19
22
  //#region src/modules/project/serveProject.ts
20
23
  /**
21
24
  * The errors whose message is the whole report — every problem, where it is and what to write instead — and whose
@@ -63,14 +66,32 @@ var actionLookups = (actions, connectors) => ({
63
66
  getConnector: (_spaceId, connectorId) => Promise.resolve(connectors.get(connectorId)),
64
67
  listScheduledSpaces: () => Promise.resolve([SPACE_ID])
65
68
  });
66
- /** The space this project serves, authored from its own source — or Plitzi's, named for `/health`. */
67
- var spaceToServe = async ({ space, cloud }) => {
69
+ /**
70
+ * The space this project serves, authored from its own source — or Plitzi's, named for `/health`.
71
+ *
72
+ * While developing, one that does not author is not the end of the server: it comes up saying why (`spaceFailure`),
73
+ * named after the project's folder, and serves the space the first time a save authors. A deployment refuses to start.
74
+ */
75
+ var spaceToServe = async ({ space, cloud }, developing, root) => {
68
76
  if (!space) return { name: cloud.name };
69
- const authored = await space();
70
- return {
71
- space: authored,
72
- name: authored.schema.definition.permanentUrl
73
- };
77
+ try {
78
+ const authored = await space();
79
+ return {
80
+ space: authored,
81
+ name: authored.schema.definition.permanentUrl
82
+ };
83
+ } catch (error) {
84
+ if (!developing) throw error;
85
+ return {
86
+ failure: isReport(error) ? error.message : error instanceof Error ? error.stack ?? error.message : String(error),
87
+ name: path.basename(root)
88
+ };
89
+ }
90
+ };
91
+ /** What is served of a space that has not authored yet: nothing — every page answers with why (`spaceFailure`). */
92
+ var NOTHING_AUTHORED = {
93
+ schema: EMPTY_SCHEMA.schema,
94
+ style: EMPTY_STYLE_SCHEMA
74
95
  };
75
96
  var startProject = async (options) => {
76
97
  const { actions, connectors = /* @__PURE__ */ new Map(), serverOptions = {} } = options;
@@ -85,16 +106,22 @@ var startProject = async (options) => {
85
106
  const port = process.env.PORT ? Number(process.env.PORT) : developing ? await freePort(8080, host) : 8080;
86
107
  /** Where people reach this server: `PUBLIC_URL` behind a proxy, its own address otherwise. */
87
108
  const publicUrl = (process.env.PUBLIC_URL ?? `http://127.0.0.1:${String(port)}`).replace(/\/+$/, "");
88
- const { space, name } = await spaceToServe(options);
109
+ const { space, failure, name } = await spaceToServe(options, developing, root);
89
110
  const plugins = await projectPlugins(root);
90
- const held = space && { documents: {
111
+ const held = options.space ? { ...space ? { documents: {
91
112
  schema: space.schema,
92
113
  style: space.style
93
- } };
114
+ } } : {} } : void 0;
115
+ const spaceFailure = createSpaceFailure();
116
+ if (failure !== void 0) {
117
+ spaceFailure.fail(failure);
118
+ console.error(`[author] ${failure}`);
119
+ console.error("[author] The server is up: fix the space and save, and the page loads it.");
120
+ }
94
121
  const speaks = !isFleetWorker();
95
122
  for (const warning of speaks ? space?.warnings ?? [] : []) console.warn(`[author] ${warning.message}`);
96
123
  const adapters = held ? createJsonAdapters({
97
- offlineData: () => held.documents,
124
+ offlineData: () => held.documents ?? NOTHING_AUTHORED,
98
125
  deployment: {
99
126
  spaceId: SPACE_ID,
100
127
  environment: "main",
@@ -115,8 +142,8 @@ var startProject = async (options) => {
115
142
  logger: consoleLogger,
116
143
  ...serverOptions,
117
144
  port,
118
- devMode: developing,
119
- devReload: developing,
145
+ devMode: serverOptions.devMode ?? developing,
146
+ devReload: serverOptions.devReload ?? serverOptions.devMode ?? developing,
120
147
  health: { name },
121
148
  adapters,
122
149
  plugins: plugins.sources,
@@ -132,7 +159,7 @@ var startProject = async (options) => {
132
159
  ...serverOptions.action,
133
160
  ...actions ? { lookups: actionLookups(actions, connectors) } : {}
134
161
  }
135
- }, { preAuth: runtime ? [runtime.stage] : [] });
162
+ }, { preAuth: [spaceFailure.stage, ...runtime ? [runtime.stage] : []] });
136
163
  server.listen(port, host);
137
164
  const url = `http://127.0.0.1:${String(port)}`;
138
165
  if (speaks) {
@@ -146,6 +173,7 @@ var startProject = async (options) => {
146
173
  }
147
174
  const watching = developing ? [...held ? [watchSpace(root, (authored) => {
148
175
  held.documents = authored;
176
+ spaceFailure.clear();
149
177
  server.reloadPages();
150
178
  })] : [], watchProjectPlugins(root, server, plugins)] : [];
151
179
  const close = async () => {
@@ -0,0 +1,8 @@
1
+ import { Stage } from '../../core/http/types';
2
+ export declare const createSpaceFailure: () => {
3
+ stage: Stage;
4
+ /** The space did not author: every page says why, until it does. */
5
+ fail: (message: string) => void;
6
+ /** It authored: the pages are the space's again. */
7
+ clear: () => void;
8
+ };
@@ -0,0 +1,76 @@
1
+ import { DEV_RELOAD_PATH } from "../../core/http/stages/devReload.js";
2
+ import { escapeHtml } from "../../helpers/escapeHtml.js";
3
+ //#region src/modules/project/spaceFailure.ts
4
+ /**
5
+ * The page a project's server answers with while its space does not author — while developing only.
6
+ *
7
+ * A `start:dev` that restarted on a save to the server's code (`src/functions/`, `src/config/`…) while the space had an
8
+ * error of its own died on it: Node then waits for a change to the server's code, never to `src/space/`, so fixing the
9
+ * space woke nothing and the page was gone with no word in the browser. Up instead, it answers every page with what is
10
+ * wrong, and loads again the moment a save authors — the same reload a page showing the space waits for — or when the
11
+ * server it reached restarts.
12
+ */
13
+ var pageOf = (message) => `<!doctype html>
14
+ <html lang="en">
15
+ <head>
16
+ <meta charset="utf-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <title>The space does not author</title>
19
+ <style>
20
+ :root { color-scheme: light dark; }
21
+ body { margin: 0; padding: 48px 24px; font: 15px/1.5 ui-sans-serif, system-ui, sans-serif; }
22
+ main { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
23
+ pre { margin: 0; padding: 16px; border-radius: 8px; background: color-mix(in srgb, currentColor 8%, transparent);
24
+ white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.5 ui-monospace, monospace; }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <main>
29
+ <h1>The space does not author</h1>
30
+ <p>Fix it and save: this page loads again the moment it authors. The server is still running.</p>
31
+ <pre>${escapeHtml(message)}</pre>
32
+ </main>
33
+ <script>
34
+ (() => {
35
+ let boot;
36
+ const events = new EventSource(${JSON.stringify(DEV_RELOAD_PATH)});
37
+ events.addEventListener('reload', () => location.reload());
38
+ events.addEventListener('hello', event => {
39
+ const next = JSON.parse(event.data).boot;
40
+ if (boot !== undefined && next !== boot) {
41
+ location.reload();
42
+ }
43
+
44
+ boot = next;
45
+ });
46
+ })();
47
+ <\/script>
48
+ </body>
49
+ </html>
50
+ `;
51
+ /** A request for a page, as a browser asks for one — not for the server's data, its assets or its reload stream. */
52
+ var asksForPage = (method, accept) => method === "GET" && [accept].flat().some((value) => value?.includes("text/html"));
53
+ var createSpaceFailure = () => {
54
+ let failure;
55
+ const stage = (ctx) => {
56
+ if (failure === void 0 || !asksForPage(ctx.req.method, ctx.req.headers.accept)) return Promise.resolve(false);
57
+ ctx.res.setStatus(503);
58
+ ctx.res.setHeader("Content-Type", "text/html; charset=utf-8");
59
+ ctx.res.setHeader("Cache-Control", "no-store");
60
+ ctx.res.send(pageOf(failure));
61
+ return Promise.resolve(true);
62
+ };
63
+ return {
64
+ stage,
65
+ /** The space did not author: every page says why, until it does. */
66
+ fail: (message) => {
67
+ failure = message;
68
+ },
69
+ /** It authored: the pages are the space's again. */
70
+ clear: () => {
71
+ failure = void 0;
72
+ }
73
+ };
74
+ };
75
+ //#endregion
76
+ export { createSpaceFailure };
@@ -1,5 +1,6 @@
1
1
  import { FunctionsDefinition } from '../functions/contract';
2
2
  import { FunctionRunner } from '../functions/protocol';
3
+ /** A refusal, however it was made: the platform's class, or a bundle's own copy of it — known by its name. */
3
4
  /**
4
5
  * A runtime's functions as a runner: the same protocol as the sandbox's, answered by code loaded in this process. The
5
6
  * driver is the sandbox's own (`createFunctionsDriver`), so a task behaves the same in both — its `ctx` is calls to the
@@ -1,11 +1,10 @@
1
- import { ActionRefusal } from "../actions/runtime/errors.js";
1
+ import { ActionRefusal, isActionRefusal } from "../actions/runtime/errors.js";
2
2
  import { changeKv } from "../actions/runtime/kvChange.js";
3
3
  import { requestFromWire, responseOf, wireResponseFrom, wireResponseOf } from "../functions/capabilities.js";
4
4
  import { FunctionFailure } from "../functions/protocol.js";
5
5
  import { createFunctionsDriver, describeFunctions } from "../functions/driver.js";
6
6
  //#region src/modules/runtime/runner.ts
7
7
  /** A refusal, however it was made: the platform's class, or a bundle's own copy of it — known by its name. */
8
- var isRefusal = (error) => error instanceof Error && error.name === "ActionRefusal";
9
8
  /**
10
9
  * A runtime's functions as a runner: the same protocol as the sandbox's, answered by code loaded in this process. The
11
10
  * driver is the sandbox's own (`createFunctionsDriver`), so a task behaves the same in both — its `ctx` is calls to the
@@ -29,7 +28,7 @@ var createRuntimeRunner = (definition = {}) => ({
29
28
  try {
30
29
  return await driver.invoke(definition, invocation);
31
30
  } catch (error) {
32
- if (isRefusal(error)) throw new FunctionFailure("refused", error.message);
31
+ if (isActionRefusal(error)) throw new FunctionFailure("refused", error.message);
33
32
  throw error;
34
33
  }
35
34
  }
@@ -1,2 +1,13 @@
1
1
  import { SSRRenderResult, SSRResponseHelpers } from '@plitzi/sdk-shared';
2
2
  export declare const applySSRResult: (res: SSRResponseHelpers, result: SSRRenderResult) => boolean;
3
+ /**
4
+ * Whether a render may be kept and served again: only a page that answered 200. A cached page is sent without the
5
+ * status it was rendered with, and a "not found" kept per address would let anybody asking for addresses at random
6
+ * push every real page out of the cache.
7
+ */
8
+ export declare const isReusable: (result: SSRRenderResult) => boolean;
9
+ /**
10
+ * A page a server provider said shows nothing (`notFound`) answers 404 — unless the render decided something of its
11
+ * own first: a redirect, or a status the navigation set.
12
+ */
13
+ export declare const withNotFound: (result: SSRRenderResult, notFound: boolean) => void;
@@ -10,5 +10,18 @@ var applySSRResult = (res, result) => {
10
10
  if (result.status !== void 0) res.setStatus(result.status);
11
11
  return false;
12
12
  };
13
+ /**
14
+ * Whether a render may be kept and served again: only a page that answered 200. A cached page is sent without the
15
+ * status it was rendered with, and a "not found" kept per address would let anybody asking for addresses at random
16
+ * push every real page out of the cache.
17
+ */
18
+ var isReusable = (result) => result.status === void 0 || result.status === 200;
19
+ /**
20
+ * A page a server provider said shows nothing (`notFound`) answers 404 — unless the render decided something of its
21
+ * own first: a redirect, or a status the navigation set.
22
+ */
23
+ var withNotFound = (result, notFound) => {
24
+ if (notFound && result.redirect === void 0 && result.status === void 0) result.status = 404;
25
+ };
13
26
  //#endregion
14
- export { applySSRResult };
27
+ export { applySSRResult, isReusable, withNotFound };
@@ -1,3 +1,4 @@
1
+ import { withNotFound } from "./applySSRResult.js";
1
2
  import Component from "./Component.js";
2
3
  import { prepareRender } from "./prepareRender.js";
3
4
  import { renderToString } from "react-dom/server";
@@ -12,6 +13,7 @@ var buildBody = async (req, config, spaceId, environment, revision, renderFn, pl
12
13
  ssrResult: result
13
14
  })).trim();
14
15
  metrics?.record("react", Math.round(performance.now() - reactStart));
16
+ withNotFound(result, prep.notFound);
15
17
  if (result.redirect !== void 0) return {
16
18
  result,
17
19
  cacheable: prep.cacheable
@@ -1,6 +1,6 @@
1
1
  import { buildHtmlCacheKey } from "../../helpers/cache/keys.js";
2
2
  import { draftSessionCookie, takeDraftOverride } from "./preview.js";
3
- import { applySSRResult } from "./applySSRResult.js";
3
+ import { applySSRResult, isReusable } from "./applySSRResult.js";
4
4
  import { buildBody } from "./buildBody.js";
5
5
  import { streamBody } from "./streamBody.js";
6
6
  import { RequestMetrics } from "../../helpers/metrics.js";
@@ -63,7 +63,7 @@ var renderSSR = async (req, res, config, renderFn, pluginManager, caches) => {
63
63
  metrics.log(`${req.method} ${req.path}`);
64
64
  }
65
65
  if (applySSRResult(res, result)) return;
66
- if (htmlCache && cacheKey && body !== void 0 && cacheable) {
66
+ if (htmlCache && cacheKey && body !== void 0 && cacheable && isReusable(result)) {
67
67
  const page = {
68
68
  html: body,
69
69
  compressed: {}
@@ -11,5 +11,7 @@ export type RenderPrep = {
11
11
  };
12
12
  /** False for a render that must not be served to anybody else from a cache: it carries this request's own runs. */
13
13
  cacheable: boolean;
14
+ /** A server provider of the page said its address shows nothing (`notFound`): sent with status 404. */
15
+ notFound: boolean;
14
16
  };
15
17
  export declare const prepareRender: (req: SSRRequest, config: SSRPageServerConfig, spaceId: number, environment: Environment, revision: number, pluginManager: PluginManager, offlineDataCache?: TtlCache<string>, metrics?: RequestMetrics, offlineDataOverride?: OfflineDataRaw) => Promise<RenderPrep>;
@@ -20,7 +20,7 @@ import { sdkAssetVersion } from "../../core/sdkAssets.js";
20
20
  import { buildServerInfo } from "../../helpers/buildServerInfo.js";
21
21
  import { resolveDebugMode } from "../../helpers/resolveDebugMode.js";
22
22
  import { flagUserFromSSR, flagValues, resolveFlags } from "@plitzi/sdk-shared/flags";
23
- import { hasServerElements } from "@plitzi/sdk-shared/schema/serverElements";
23
+ import { collectServerElements, notFoundProvider, pageNotFound, pageServerContext } from "@plitzi/sdk-shared/schema/serverElements";
24
24
  import { debugCookieName } from "@plitzi/sdk-shared/devTools";
25
25
  import { themeFromCookies } from "@plitzi/sdk-shared/theme";
26
26
  import { paintedKeys, paintedStateFor } from "@plitzi/sdk-shared/state/paintedState";
@@ -74,7 +74,8 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
74
74
  queryParams: req.query,
75
75
  user: flagUserFromSSR(req.ctx.user)
76
76
  }, flagOverrides)) : void 0;
77
- const rscData = rscEnabled && pageMatch !== void 0 && hasServerElements(schema, pageMatch.pageId, pageFlags) && config.adapters.getRscData ? await m("rsc", () => config.adapters.getRscData?.({
77
+ const serverElements = rscEnabled && pageMatch !== void 0 ? collectServerElements(schema, pageMatch.pageId, void 0, pageFlags) : [];
78
+ const rscData = serverElements.length > 0 && config.adapters.getRscData ? await m("rsc", () => config.adapters.getRscData?.({
78
79
  req,
79
80
  spaceId: req.ctx.spaceDeployment?.spaceId ?? spaceId,
80
81
  environment: req.ctx.spaceDeployment?.environment ?? environment,
@@ -83,7 +84,13 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
83
84
  loadOfflineData,
84
85
  flagOverrides
85
86
  })) : rscPath ? { serverData: {} } : void 0;
86
- const pageSeo = resolvePageSeo(schema, pageMatch?.pageId);
87
+ const navigation = {
88
+ routeParams: pageMatch?.routeParams ?? {},
89
+ queryParams: req.query
90
+ };
91
+ const pageContext = pageServerContext(serverElements, rscData?.serverData ?? {}, navigation);
92
+ const pageSeo = resolvePageSeo(schema, pageMatch?.pageId, pageContext);
93
+ const notFound = pageMatch !== void 0 && rscData?.serverData !== void 0 ? notFoundProvider(serverElements, rscData.serverData, navigation) !== void 0 || pageNotFound(schema?.flat[pageMatch.pageId]?.attributes.notFound, pageContext) : false;
87
94
  const server = buildServerInfo(req, config, {
88
95
  rscPath,
89
96
  rscData,
@@ -281,7 +288,8 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
281
288
  devReload: config.devReload === true,
282
289
  offlineData: offlineDataStr
283
290
  },
284
- cacheable: actionRuns === void 0 && forcedFlags === void 0
291
+ cacheable: actionRuns === void 0 && forcedFlags === void 0,
292
+ notFound
285
293
  };
286
294
  };
287
295
  //#endregion
@@ -14,5 +14,8 @@ export type PageSeo = {
14
14
  * `seoEnabled: false` returns nothing rather than a default, so the deployment's own title stays in charge of a
15
15
  * page that opted out. Blank strings are treated as absent for the same reason — the builder writes one for a
16
16
  * field the author cleared, and an empty <title> is worse than a generic one.
17
+ *
18
+ * Either may be a template over the page's server providers and the address (`pageServerContext`): a detail page says
19
+ * the title of the record it shows, in the HTML a crawler reads. One that does not evaluate is nothing, as a blank is.
17
20
  */
18
- export declare const resolvePageSeo: (schema: Schema | undefined, pageId: string | undefined) => PageSeo;
21
+ export declare const resolvePageSeo: (schema: Schema | undefined, pageId: string | undefined, context?: Record<string, unknown>) => PageSeo;
@@ -1,3 +1,4 @@
1
+ import { pageSeoText } from "@plitzi/sdk-shared/schema/serverElements";
1
2
  //#region src/modules/ssr/resolvePageSeo.ts
2
3
  /**
3
4
  * The title and description the addressed page declares for itself.
@@ -10,13 +11,16 @@
10
11
  * `seoEnabled: false` returns nothing rather than a default, so the deployment's own title stays in charge of a
11
12
  * page that opted out. Blank strings are treated as absent for the same reason — the builder writes one for a
12
13
  * field the author cleared, and an empty <title> is worse than a generic one.
14
+ *
15
+ * Either may be a template over the page's server providers and the address (`pageServerContext`): a detail page says
16
+ * the title of the record it shows, in the HTML a crawler reads. One that does not evaluate is nothing, as a blank is.
13
17
  */
14
- var resolvePageSeo = (schema, pageId) => {
18
+ var resolvePageSeo = (schema, pageId, context = {}) => {
15
19
  if (!schema || !pageId) return {};
16
20
  const page = schema.flat[pageId];
17
21
  if (!page?.attributes.seoEnabled) return {};
18
- const title = page.attributes.seoPageTitle?.trim();
19
- const description = page.attributes.seoPageDescription?.trim();
22
+ const title = pageSeoText(page.attributes.seoPageTitle, context);
23
+ const description = pageSeoText(page.attributes.seoPageDescription, context);
20
24
  return {
21
25
  ...title ? { title } : {},
22
26
  ...description ? { description } : {}
@@ -1,4 +1,4 @@
1
- import { applySSRResult } from "./applySSRResult.js";
1
+ import { applySSRResult, isReusable, withNotFound } from "./applySSRResult.js";
2
2
  import Component from "./Component.js";
3
3
  import { prepareRender } from "./prepareRender.js";
4
4
  import { renderToPipeableStream } from "react-dom/server";
@@ -30,7 +30,7 @@ var streamBody = async (req, res, config, spaceId, environment, revision, render
30
30
  const tailBuf = Buffer.from(tail, "utf-8");
31
31
  res.write(tailBuf);
32
32
  res.end();
33
- if (chunks && cacheKey && htmlCache) htmlCache.set(cacheKey, {
33
+ if (chunks && cacheKey && htmlCache && isReusable(result)) htmlCache.set(cacheKey, {
34
34
  html: head + Buffer.concat(chunks).toString("utf-8") + tail,
35
35
  compressed: {}
36
36
  });
@@ -49,6 +49,7 @@ var streamBody = async (req, res, config, spaceId, environment, revision, render
49
49
  res.setHeader("Server-Timing", metrics.toServerTimingHeader());
50
50
  metrics.log(`${req.method} ${req.path}`);
51
51
  }
52
+ withNotFound(result, prep.notFound);
52
53
  if (applySSRResult(res, result)) {
53
54
  abort();
54
55
  resolve();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.38.8",
3
+ "version": "0.38.10",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -96,8 +96,8 @@
96
96
  "./dist/env.js"
97
97
  ],
98
98
  "dependencies": {
99
- "@plitzi/plitzi-sdk": "0.38.8",
100
- "@plitzi/sdk-shared": "0.38.8",
99
+ "@plitzi/plitzi-sdk": "0.38.10",
100
+ "@plitzi/sdk-shared": "0.38.10",
101
101
  "ejs": "^6.0.1",
102
102
  "esbuild": "^0.28.2",
103
103
  "jsonwebtoken": "^9.0.3",
@@ -50,7 +50,7 @@ apiContainer({ id: 'p', query: '/data/x.json', runtime: 'server', children }) //
50
50
  apiContainer({ id: 'p', action: 'report', runtime: 'server', children }) // the action's output at p's root: p.rows
51
51
  // loadingSlot: 'p-skeleton' — a child shown until it answers
52
52
  form({ id, managedByInteractions: true, flows, children })
53
- formControl({ name: 'email', label: 'Email', subType: 'email' }) // select: options: [{ label, value }]
53
+ formControl({ name: 'email', label: 'Email', subType: 'email', required: true }) // optional without required; select: options: [{ label, value }]
54
54
  modalContainer({ id, visible: false, title, children })
55
55
  component('card', { props: { title }, children: { body: [ … ] } })
56
56
  custom({ renderType: 'myPlugin' }); defineElement<Props>(declaration) // a plugin
@@ -1,7 +1,8 @@
1
1
  /* eslint-disable quotes -- templates quote their own strings, and read best in the other quotes */
2
2
  /**
3
3
  * A page per record: a card links to `/products/<slug>`, and the detail page narrows the same file to the product its
4
- * address names — with a message, not an empty page, when no product has that name.
4
+ * address names — with a message, not an empty page, when no product has that name, and status 404: the provider is
5
+ * resolved on the server, and `notFound` says when its answer holds nothing for this address.
5
6
  */
6
7
  import { apiContainer, heading, link, list, text, listItem } from '@plitzi/sdk-authoring';
7
8
 
@@ -48,7 +49,8 @@ export const recipe: SpaceSpec = {
48
49
  apiContainer({
49
50
  id: 'catalog-one',
50
51
  query: '/data/products.json',
51
- cache: true,
52
+ runtime: 'server',
53
+ notFound: "{{ not (source.data.products|find('slug', navigation.routeParams.slug)) }}",
52
54
  children: [
53
55
  list({
54
56
  id: 'this-product',
@@ -31,8 +31,6 @@ export const recipe: SpaceSpec = {
31
31
  { label: 'Graphics cards', value: 'gpu' }
32
32
  ],
33
33
  defaultValue: 'all',
34
- // A filter always has an answer: the select is not a question that must be filled in.
35
- required: false,
36
34
  flows: [
37
35
  [named('picked', on('onChange')), setState({ key: 'category', type: 'text', value: '{{ picked.value }}' })]
38
36
  ]
@@ -38,7 +38,8 @@ export const recipe: SpaceSpec = {
38
38
  [named('sent', onSubmit()), setState({ key: 'email', type: 'text', value: '{{ sent.values.email }}' })]
39
39
  ],
40
40
  children: [
41
- formControl({ name: 'email', label: 'Email', subType: 'email' }),
41
+ // Optional unless it says so, as an HTML field is: a sign-up without an address is no sign-up.
42
+ formControl({ name: 'email', label: 'Email', subType: 'email', required: true }),
42
43
  button({ content: 'Sign up', subType: 'submit' })
43
44
  ]
44
45
  }),
@@ -38,6 +38,8 @@ image({ src: '/grain.png', decorative: true }) // an orname
38
38
  image({ decorative: true, bind: { src: 'post.record.cover' } }) // a cover under its own headline adds nothing
39
39
  image({ bind: { src: 'row.item.cover', alt: 'row.item.title' } }) // …unless it is alone in a link: then it names it
40
40
  fontAwesome({ icon: 'fa-solid fa-triangle-exclamation', label: 'Overdue' }) // an icon that says something alone
41
+ text('福', { decorative: true }) // a seal or a mark drawn for the look alone
42
+ list({ label: 'Kinds of capsule', … }) // a group of options is named, as a group
41
43
  ```
42
44
 
43
45
  ## Clicks go on controls
@@ -101,6 +101,8 @@ The space is not written until these are fixed.
101
101
  | `motion-invalid` | a `motion` with a preset, a trigger or a timing the page cannot play | `motion: { enter: 'fade-up', on: 'view' }` — the message lists the presets |
102
102
  | `motion-no-tag` | a `motion` on an element with no tag of its own — nothing to move | the motion on what it wraps, or a `subType` on it |
103
103
  | `no-pages` | a space with no pages | `pages: [{ id: 'home', name: 'Home', slug: '', body: [] }]` |
104
+ | `not-found-in-browser` | `notFound` on a provider asked from the browser — its answer arrives after the page was sent with its status | `runtime: 'server'` on the provider, or `visible` on the page's "not found" part instead |
105
+ | `not-found-not-a-template` | `notFound`, a provider's or a page's, that is not one `{{ expression }}` — never `true`, so never a 404 | `notFound: '{{ source.found == false }}'` |
104
106
  | `notifications-shape` | `notifications` with a field it does not have, or a value that is not one CSS value | the field it suggests, or one it lists; one CSS value, like 'var(--card)', '12px' or '500' |
105
107
  | `outside-ancestor` | an element that reads the state of an element it is not inside | nest it in the element the message names |
106
108
  | `page-access-level` | an `accessLevel` that is not one the router reads | one of the values the message lists |
@@ -108,6 +110,7 @@ The space is not written until these are fixed.
108
110
  | `page-route-taken` | two pages at one address for the same visitors | another slug — or `accessLevel` `'public'` on one and `'authenticated'` on the other |
109
111
  | `page-target-unknown` | a link or `navigate` to a page id that does not exist | an existing page id, or a path with its slash (`'/about'`) |
110
112
  | `page-target-url` | a URL, `mailto:` or `tel:` in a link left in page mode | `mode: 'external'` |
113
+ | `page-template` | a template in a page's `seoTitle`, `seoDescription` or `notFound` that reads what is not there when the server answers — anything but the page's `runtime: 'server'` providers and `navigation` — or cannot be read | read the record from a provider with `runtime: 'server'` on the page or its layout — `'{{ apiContainer_post.title }} — Blog'` — or words of its own |
111
114
  | `page-without-slug` | a page with no `slug` | `slug: ''` for the home page, its path for any other: `'about'`, `'blog/:slug'` |
112
115
  | `part-missing` | a compound element — a carousel, a tab container, a dropdown — without a part it renders through | the part the message names, inside it (`carousel()` writes its own track) |
113
116
  | `prop-missing` | an instance without a prop its component requires | `component('card', { props: { name: … } })`, or bind it |
@@ -183,6 +186,7 @@ The space renders, and renders something you probably did not mean. Fix every on
183
186
  | `embed-without-title` | an `embed` with no `title` — a frame a screen reader cannot describe | say what it shows in `title` |
184
187
  | `flag-rule-empty` | a flag rule with no conditions — skipped, never read as "always" | give it a condition, or set the flag's `value` instead |
185
188
  | `flag-unused` | a declared flag nothing gates on and no template reads | gate what it switches (`flag: 'x'`), or remove it once the feature has shipped |
189
+ | `focus-on-field-box` | a `focus` or `focus-visible` state on a text field's or a select's `input` slot — the box the field is drawn in, which never takes focus, so no ring is ever shown | `'focus-within'` on the same class, or the state on the `field` slot |
186
190
  | `FORM_SUBMIT_UNMANAGED` | a form the browser would submit itself, reloading the page | `managedByInteractions: true` |
187
191
  | `form-control-name-taken` | two controls in one form with one name — one overwrites the other | a name each |
188
192
  | `form-control-unnamed` | a control in a form with no `name` — its value never reaches `values` | `formControl({ name: 'email', … })` |
@@ -200,6 +204,7 @@ The space renders, and renders something you probably did not mean. Fix every on
200
204
  | `path-not-in-data` | a binding onto a provider whose answer the author could read (`data`), through a path that answer does not have | the path the message lists the keys for — `p.data.plans`, not `p.data.landing.plans` |
201
205
  | `plugin-attribute-reserved` | a plugin attribute named as one of the element’s own fields — a factory never hands it to the plugin | rename it in the plugin (`variant` → `kind`) |
202
206
  | `provider-without-source` | an `apiContainer` that asks nothing | a `query` (or `action`, `connector`, `resource`) |
207
+ | `required-message-unused` | a `requiredMessage` on a field nothing requires — fields are optional unless `required: true`, so it is never shown and an empty answer is sent | `required: true` on the field, or no message |
203
208
  | `route-param-undeclared` | `navigation.routeParams.x` read on a page whose slug has no `:x` — always empty | add `:x` to the slug, or read `navigation.queryParams.x` |
204
209
  | `server-data-without-rsc` | a `runtime: 'server'` provider — `connector`, `action` or `query` — in a space that turns server data off (`rsc: { enabled: false }`) | drop `rsc: { enabled: false }`: server data is on unless a space turns it off |
205
210
  | `span-holds-block` | a `container` with `subType: 'span'` or `'p'` holding a heading, a paragraph, a list, a form or prose | a `div` (leave `subType` out), or words and inline elements: a `text`, a `link` |
@@ -229,6 +234,7 @@ Nothing is wrong: the page renders as written. Each is a shorter way to the same
229
234
  | `custom-css-slot` | a part of a built-in element dressed in `customCss` by the SDK's markup — a form control's `<input>`, a pager's buttons, a heading inside a markdown | a class on the slot of that part — `slots: { field: input }`, `elements.<type>.slots` — with its states |
230
235
  | `heavy-animation` | keyframes animating what the browser repaints or lays out again on every frame — a size, a position, a blur, a shadow, or a colour in a loop — which stutters whenever the page is busy, most of all while it loads | `opacity` and `transform`: a size or a position is `translate`/`scale`, a blur or a shadow is the `opacity` of a layer carrying it; a loop that must animate anything else starts `paused` and runs under `[data-hydrated]` |
231
236
  | `literal-colour` | a colour written out in a class where the space has a token of that value — it stays put in the dark scheme | `var(--token)` when it should follow the scheme; a token of one value of its own when it must stay the same in both |
237
+ | `not-found-page` | a space with no page of its own for an address no other page answers — a plain one was added, in the home page’s layout, sent with status 404 | a page whose slug is `'*'`: `{ id: 'not-found', name: 'Not found', slug: '*', layout: …, body: [...] }` |
232
238
  | `repeated-on-pages` | the same block — a header, a footer, a side panel — written into several pages | a layout holding it once (`layouts`, and `layout: { id, slot }` on each page) — a component when only some pages of a layout carry it; a link marks its own page with the `current` state |
233
239
  | `repeated-shape` | the same structure written again and again with different words — cards, rows, tiles | a component with props (`components`, `component(id, { props })`), or one `list` when they are rows of data side by side — a few cards a person rewords on the canvas can stay cards |
234
240
  | `unused-class` | a class declared in `classes` that no element, binding, flow or other class names | remove it from `classes` — or wear it where it was meant to go |
@@ -131,8 +131,8 @@ form({
131
131
  });
132
132
  ```
133
133
 
134
- A `formControl` is `required` by default: an optional field says `required: false`, or an empty one stops the submit
135
- without a word.
134
+ A `formControl` is optional unless it says `required: true`, as an HTML field is. A required field that is empty stops
135
+ the submit, and the browser says which.
136
136
 
137
137
  ## Notifications
138
138
 
@@ -124,6 +124,26 @@ pages: pageFamily(
124
124
 
125
125
  Every page is now its content and nothing else, and every page is guaranteed the same structure.
126
126
 
127
+ ## The page for an address nothing answers
128
+
129
+ ```ts
130
+ { id: 'not-found', name: 'Not found', slug: '*', layout: { id: 'site', slot: 'site-main' }, body: [ … ] }
131
+ ```
132
+
133
+ - **`slug: '*'`** is the page of every address no other page answers — a mistyped link, a page taken down — sent with
134
+ status 404, in the layout you give it, so the visitor is still on the site with a way back. Its own address answers
135
+ 404 too.
136
+ - **One per folder, if a section wants its own.** In a folder (`folder: 'docs'`) it answers that folder's unknown
137
+ addresses; the deepest folder's wins, the space's takes the rest. A page whose `flag` is off shows it too.
138
+ - **A space with none is given a plain one** — a title and a link home, in the home page's layout — and `author`
139
+ suggests writing your own (`not-found-page`): say it in the space's words.
140
+ - **A page that exists but finds nothing** — `/products/:slug` for a slug no product has — is sent with 404 too when
141
+ its server provider says so: `notFound: "{{ not (source.data.products|find('slug', navigation.routeParams.slug)) }}"`,
142
+ one expression against its answer, on a `runtime: 'server'` provider of the page. When the record comes from a
143
+ provider of its layout, which every page shares, the page says it: `notFound` on the page reads its server providers
144
+ by name — `"{{ not (apiContainer_feed.products|find('slug', navigation.routeParams.slug)) }}"`. The page renders as
145
+ written: show its "not found" part with `visible`.
146
+
127
147
  ## Styles follow the same rule
128
148
 
129
149
  - A role used on several pages is ONE class (`panelCard`, `pageTitle`), declared in the module that owns the area and
@@ -9,7 +9,7 @@ Each is a whole file that authors with no warning — CI holds it to that. Copy
9
9
  | Data read by the page server (`--mode server`) | [recipes/server-data.ts](../recipes/server-data.ts) |
10
10
  | An accordion, one answer open at a time | [recipes/accordion.ts](../recipes/accordion.ts) |
11
11
  | Filter and sort a list from a select | [recipes/filter-a-list.ts](../recipes/filter-a-list.ts) |
12
- | A page per record (`/products/:slug`), and "not found" | [recipes/detail-page.ts](../recipes/detail-page.ts) |
12
+ | A page per record (`/products/:slug`), and "not found" sent with 404 | [recipes/detail-page.ts](../recipes/detail-page.ts) |
13
13
  | Link to a section of a page (`/#plans`) | [recipes/link-to-a-section.ts](../recipes/link-to-a-section.ts) |
14
14
  | A carousel: slides with arrows, dots and autoplay; a marquee; a row that swipes | [recipes/carousel.ts](../recipes/carousel.ts) |
15
15
  | Do something every few seconds — a ticker, a poll, a slideshow by hand | [recipes/every-few-seconds.ts](../recipes/every-few-seconds.ts) |
@@ -93,7 +93,9 @@ pages: [
93
93
 
94
94
  Adding a page is adding an entry; two entries of one id or slug are refused as any two pages are. For one shape over
95
95
  records a visitor browses — a product, a post — a single page with a route param (`slug: 'posts/:postId'`) reading
96
- the record is the answer instead: one page, as many addresses as records.
96
+ the record is the answer instead: one page, as many addresses as records. Its `seoTitle` can say the record —
97
+ `'{{ apiContainer_post.title }} — Blog'` — read from a provider on the page with `runtime: 'server'`, or from
98
+ `navigation`: the server writes the head with it, so a link preview shows it. Anything else is refused (`page-template`).
97
99
 
98
100
  ## Names
99
101