@plitzi/sdk-server 0.38.13 → 0.38.14

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 (37) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +17 -5
  3. package/dist/core/auth/session.d.ts +10 -2
  4. package/dist/core/auth/session.js +31 -6
  5. package/dist/core/http/stages/sessionRenewal.js +2 -1
  6. package/dist/core/http/stages/signInRoute.js +1 -1
  7. package/dist/core/http/stages/static.js +1 -1
  8. package/dist/core/mimeTypes.d.ts +10 -1
  9. package/dist/core/mimeTypes.js +2 -1
  10. package/dist/core/oauth/authorize.js +2 -1
  11. package/dist/core/requestParser.d.ts +6 -0
  12. package/dist/core/requestParser.js +8 -2
  13. package/dist/core/requestParser.test.d.ts +1 -0
  14. package/dist/core/services/action.js +1 -1
  15. package/dist/core/services/realtime.js +1 -1
  16. package/dist/core/staticFiles.d.ts +2 -1
  17. package/dist/core/staticFiles.js +5 -5
  18. package/dist/helpers/cache/keys.d.ts +1 -0
  19. package/dist/helpers/cache/keys.js +4 -3
  20. package/dist/helpers/flagOverrides.d.ts +2 -0
  21. package/dist/helpers/flagOverrides.js +2 -1
  22. package/dist/modules/project/serveProject.d.ts +3 -2
  23. package/dist/modules/project/serveProject.js +81 -14
  24. package/dist/modules/realtime/socket.js +1 -1
  25. package/dist/modules/rsc/handler.js +2 -1
  26. package/dist/modules/runtime/elsewhere.test.d.ts +1 -0
  27. package/dist/modules/runtime/inside.d.ts +21 -0
  28. package/dist/modules/runtime/inside.js +51 -3
  29. package/dist/modules/runtime/ownServer.test.d.ts +1 -0
  30. package/dist/modules/ssr/prepareRender.js +3 -2
  31. package/dist/plugins/manager.d.ts +9 -0
  32. package/dist/plugins/manager.js +43 -1
  33. package/dist/runtime.d.ts +2 -1
  34. package/dist/runtime.js +2 -2
  35. package/package.json +3 -3
  36. package/skills/plitzi-authoring/reference/layouts.md +10 -0
  37. package/skills/plitzi-authoring/reference/plugins.md +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,154 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.38.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 493955a: ## SDK
8
+
9
+ - **An embedded space leaves the page's theme alone** (`@plitzi/plitzi-sdk`, `@plitzi/plitzi-builder`): plitzi-ui's
10
+ `Provider` stamps `dark` on `<html>` by default — and, being `light`, takes it off — so every space mounted with
11
+ `themeScope: 'container'` turned the page around it light, and nothing put it back. The SDK and the builder now leave
12
+ the class to their own theme provider (`applyColorModeClass={false}`).
13
+ - **A space inside a space** (`@plitzi/plitzi-sdk`, `@plitzi/sdk-shared`): one drawn inside another — the `plitziSdk`
14
+ element, a plugin rendering one, the builder's canvas — knows it (`render.enclosed`). It is `container` unless it
15
+ says otherwise, wears its theme on its own root and starts from the page's when its own is `system`, keeps its
16
+ stylesheet to its own root (`@scope` — its `:root` palette and `.dark` rules repainted the outer page, its
17
+ `.plitzi-sdk` rule restyled the outer root), writes no `<title>` (`ownsHead`) and no `theme` cookie.
18
+ - **The `plitziSdk` element draws** (`@plitzi/plitzi-sdk`): it rendered in `widget` mode with no page to show, so it
19
+ drew nothing. A widget with no `currentPageId` now shows the page its home address would. And it takes a space's
20
+ documents as well as a key: `offlineData`, bound whole (an `apiContainer`'s answer, a runtime's route, what
21
+ `plitzi_render` returns) or as `offlineData.schema` and `offlineData.style` from two elements — waiting, without an
22
+ error, for the half that has not answered; one that cannot be drawn is said why, in the console and on the canvas.
23
+ - **`<PlitziSdk>` inside a plugin draws** (`@plitzi/plitzi-sdk`): it mounted a router of its own inside the page's, and
24
+ React Router refused it — `routing: 'memory'` or not. A space inside another is a widget: `raw` becomes `widget`
25
+ there, one page and no router (a frame of its own, `iframe` or `shadow`, is left as asked).
26
+ - **A space inside another is as tall as what it holds** (`@plitzi/plitzi-sdk`): its root was `min-h-screen`, as a
27
+ space that IS the page is, so a card of three lines drawn in a panel took the window's height.
28
+ - **`<PlitziSdk.Plugin component>` takes a component with its own props** (`@plitzi/plitzi-sdk`), as `render()`'s
29
+ plugins do: typed `ComponentPluginFC`, it refused any component that read its attributes.
30
+ - **An edited page keeps its address in step** (`@plitzi/plitzi-sdk`): the routes are built from the pages' own
31
+ elements, and those were read again only when the list of pages changed — an edit to one page (its slug, its flag)
32
+ leaves that list the same, and the routes kept its old address until something else rebuilt them.
33
+ - **Packages declare only what they use** (`@plitzi/sdk-dev-tools`, `@plitzi/sdk-shared`): `@plitzi/sdk-plugins`, which
34
+ the dev tools no longer import, and `terser`, which no build here runs, are no longer dependencies.
35
+ - **An edited page keeps its route up to date** (`@plitzi/plitzi-sdk`): the routes are built from the pages' own
36
+ elements, which were read again only when the list of pages changed — an edit to one page (its slug, its flag) left
37
+ the list the same and the route at the old address. They follow every edit to a page now, and stay the same object
38
+ while no page changes.
39
+ - **`@plitzi/sdk-dev-tools` no longer depends on `@plitzi/sdk-plugins`**, which nothing in it imports.
40
+ - **A dropdown's menu opens inside a card that lifts or clips** (`@plitzi/sdk-elements`, `@plitzi/sdk-shared`): drawn
41
+ inside the dropdown, an ancestor with a `transform` — a card lifting on hover — made itself the containing block of
42
+ the menu's `position: fixed`, and one with `overflow: hidden` cut it off: the menu opened and nobody saw it. The open
43
+ menu and its backdrop are drawn at the space's root (`spaceRootOf`, `@plitzi/sdk-shared/helpers/spaceRoot`); a
44
+ style written for the popup by an ancestor's selector no longer reaches it — its own classes do.
45
+ - **A copy button says when it could not copy** (`@plitzi/sdk-elements`, `@plitzi/sdk-shared`,
46
+ `@plitzi/sdk-interactions`, `@plitzi/sdk-variables`, `@plitzi/sdk-dev-tools`): a page not served over https has no
47
+ clipboard — a phone opening a development server by its address — and a code block's "Copy" threw there. It reads
48
+ "Not copied", and says why. `clipboardWriter` and `copyText` (`@plitzi/sdk-shared/helpers/clipboard`) are the one
49
+ check, the `copyToClipboard` step's too.
50
+
51
+ ## Authoring
52
+ - **`plitziSdk` is authored like any element** (`@plitzi/sdk-authoring`, `@plitzi/sdk-elements`): its declaration moved
53
+ to `@plitzi/sdk-elements` beside the others, so it has a factory — `plitziSdk({ id, bind: { offlineData: 'view.data'
54
+ } })` —, its attributes typed, and `plitzi explain` knows it.
55
+ - **A binding may write part of an attribute** (`@plitzi/sdk-authoring`): `offlineData.schema` writes into
56
+ `offlineData`, as the runtime always did; authoring refused it as an attribute nothing reads.
57
+ - **`choosesChildren`** (`@plitzi/sdk-shared`, `@plitzi/sdk-authoring`): a plugin that shows only some of the elements
58
+ put inside it — a dashboard's panels, a wizard's step — says so in its declaration, and the page checks owe none of
59
+ them on screen; what it draws is still read by the checks of the whole page (images, sideways scroll, legibility).
60
+ `verify` was red on every such page.
61
+
62
+ - **A page says how the document scrolls** (`@plitzi/sdk-elements`, `@plitzi/sdk-authoring`, `@plitzi/sdk-mcp`):
63
+ `documentScroll: 'locked'` holds the document still while the page is open — a dashboard or a board used with a
64
+ finger, where the whole page was dragged and bounced back though nothing in it scrolls — and `'noBounce'` keeps the
65
+ scroll without the rubber band or pull-to-refresh. The page draws the rule itself, so it is in the server's HTML and
66
+ leaves with the page; never inside a widget, a space drawn in another, or the builder's canvas. A Settings select,
67
+ `PageSpec.documentScroll`, `upsertPage`'s `documentScroll` (`null` undoes it) and the page read; an unknown value is
68
+ refused (`attribute-value`). `upsertPage` is now held to the lint of the page it writes, as element writes are.
69
+
70
+ ## MCP
71
+ - **`@plitzi/sdk-mcp/render`** (`@plitzi/sdk-mcp`): `plitzi_render`'s widget render on its own — no MCP server, no SSR,
72
+ no OAuth — for a project whose own agent draws in its own pages: `render`, `renderWidgetShape` to declare it as a
73
+ tool, `renderGuideText` for the agent. `base`, the style of the project's space, brings its tokens, classes and fonts
74
+ to the widget.
75
+
76
+ ## Server
77
+ - **`publicUrl` says where people reach the server** (`@plitzi/sdk-server`): it was `http://127.0.0.1:<port>` whatever
78
+ the server was. It is `https` once `serverOptions.tls` gives it a certificate, and this machine's network address
79
+ with `HOST` open to the network (what a tablet opens, what an OAuth provider sends it back to); `PUBLIC_URL` still
80
+ wins. The network addresses it prints say `https` too.
81
+ - **A server serving TLS reaches itself** (`@plitzi/sdk-server`): with a local certificate (mkcert) Node trusts nothing
82
+ the browsers were told to, so a runtime calling its own MCP failed. Its requests to its own address now connect to its
83
+ listener and are taken when it presents exactly its certificate (`reachOwnServer`, exported from `/runtime`).
84
+ - **A runtime reads compressed answers again** (`@plitzi/sdk-server`): the dispatcher `reachOwnServer` (and the
85
+ platform's `reachSpaceInside`) installs reached every other host with an undici connector that offers HTTP/2, and an
86
+ answer that came back over h2 reached Node's `fetch` without its `content-encoding` — every API that compresses read
87
+ as Brotli bytes. Other hosts are reached over HTTP/1.1 now, as Node's own `fetch` reaches them.
88
+ - **A project's server serves HTTPS from `.env`** (`@plitzi/sdk-server`, `@plitzi/sdk-shared`): `TLS_CERT` and
89
+ `TLS_KEY`, the certificate's and the key's paths — what `plitzi cert` writes — with no code of the project's;
90
+ `serverOptions.tls` still wins. A network address the certificate does not name is said at boot, the machine's
91
+ `.local` name is printed among the addresses when it is named, and a server on loopback alone says how to open it to
92
+ the network.
93
+ - **A session survives a phone on the Wi-Fi** (`@plitzi/sdk-server`): a private network address (`192.168.…`, `10.…`,
94
+ Tailscale's `100.64/10`, IPv6 unique- and link-local) and a machine's mDNS name (`studio.local`) get the local
95
+ cookie profile — `SameSite=Lax`, no `Secure`, no `Domain`. They got `SameSite=None; Secure`, which a browser drops
96
+ over http: nobody could sign in from a tablet. A deployment's own `.local` sub-domains (`app.example.local`) keep the
97
+ strict profile.
98
+ - **Hiding the dev tools on a server over HTTPS hydrates** (`@plitzi/sdk-server`): a server with a certificate speaks
99
+ HTTP/2, which sends no `Host` — only `:authority` — and the cookies named for the origin's port (the dev tools'
100
+ `plitzi_debug_<port>`, forced flags, painted state) were looked for without it. Shift+F12 hid the dev tools in the
101
+ browser, the server rendered them back, and the page failed to hydrate. Every one is read off `requestAuthority`.
102
+ - **A rebuild of plugins says it finished** (`@plitzi/sdk-server`): bundles removed under a running server (`tmp/`
103
+ deleted) were each a warning, "rebuilding…", and their "ready" at a level a project's terminal does not show. It is
104
+ one episode now: said when noticed, and again when the last is built — how many, how long, which failed.
105
+ - **`tmp/dev-server.json` records the scheme** (`@plitzi/sdk-server`): `url` is `https` with a certificate, and the
106
+ file adds the network addresses and the `publicUrl`.
107
+ - **`public/` is asked about every time** (`@plitzi/sdk-server`): it was cached for an hour — a `.js` or `.css` in it
108
+ for a year, `immutable` — so a file a project rewrites while it runs arrived stale. It is `no-cache` now, answered
109
+ `304` by its `ETag` while unchanged; `immutable` stays for what is versioned (the SDK, the plugins).
110
+
111
+ ## CLI
112
+ - **`check`, `shot`, `verify` and `visual` find a server on HTTPS** (`@plitzi/cli`): they rebuilt
113
+ `http://127.0.0.1:<port>`, so a project serving a certificate had no page checked. They read the URL `npm start`
114
+ recorded, and take a local certificate on this machine only. A project's `playwright.config.ts` comes from
115
+ `plitzi upgrade`.
116
+ - **`verify` runs the project's tests** (`@plitzi/cli`): a step `test`, after the lint, when `package.json` has a
117
+ `test` script.
118
+ - **`tsconfig.json` is the project's** (`@plitzi/cli`): the compiler options moved to `plitzi/tsconfig.base.json`, which
119
+ the CLI keeps up and `tsconfig.json` extends — so a folder of the project's own (`scripts/`, `tools/`) goes in its
120
+ `include` without changing a file of the CLI's. `plitzi doctor --fix` makes an older `tsconfig.json` extend it,
121
+ keeping only the options the project changed.
122
+ - **`doctor --fix` and `upgrade` write as the project's Prettier does** (`@plitzi/cli`): a repaired `tsconfig.json` or
123
+ `package.json` left `verify`'s format step red.
124
+ - **`upgrade` renames `usePlitziServiceContext`** (`@plitzi/cli`): with `PlitziServiceProvider` and
125
+ `PlitziServiceContextValue`, renamed in 0.38.12 with no alias — import, use and the module path that named it.
126
+ - **`skills update` compares every file** (`@plitzi/cli`): only `SKILL.md` was, so a change in a reference alone was
127
+ "up to date".
128
+ - **`NOTES.md`, the project's notes for agents** (`@plitzi/cli`): `AGENTS.md` and `CLAUDE.md` are the CLI's and
129
+ `upgrade` replaces them, so what a project's own sessions had to know had nowhere to live. `CLAUDE.md` imports
130
+ `NOTES.md`, `AGENTS.md` sends every agent to it, and nothing the CLI does touches it; `doctor --fix` writes one for
131
+ a project that has none.
132
+
133
+ - **`start:dev-inspect`** (`@plitzi/cli`): a server project's `start:dev` with Node's inspector open on
134
+ 127.0.0.1:9229, so a debugger (VS Code, `chrome://inspect`) attaches to the server, and again after each restart.
135
+ `plitzi upgrade` adds it; `plitzi runtime add` has it restart on the runtime too.
136
+ - **`plitzi cert`** (`@plitzi/cli`): HTTPS while developing, for a phone or a tablet on the Wi-Fi — over http its
137
+ browser gives a page no microphone, camera or clipboard. A certificate from mkcert's local authority for this machine,
138
+ its network addresses and its `.local` name, in `tmp/tls/`, named in `.env`; what to install on the phone, said.
139
+ - **A browser project opens to the network** (`@plitzi/cli`): its `vite.config.ts` reads `HOST`, `TLS_CERT` and
140
+ `TLS_KEY` from `.env`, as a server project does, and writes `tmp/dev-server.json` — the port Vite took and the scheme
141
+ — so `page check`, `page shot` and `visual` find it on another port or over HTTPS. `plitzi upgrade` brings it.
142
+
143
+ ## Docs
144
+ - **Two plugins share through `state`** (`@plitzi/sdk-authoring` skill): each plugin is a bundle of its own, so a module
145
+ both import is two copies, and what one keeps there the other never sees.
146
+ - **`verify --help`** names the project's tests among its steps.
147
+
148
+ - Updated dependencies [493955a]
149
+ - @plitzi/plitzi-sdk@0.38.14
150
+ - @plitzi/sdk-shared@0.38.14
151
+
3
152
  ## 0.38.13
4
153
 
5
154
  ### Patch Changes
package/README.md CHANGED
@@ -210,7 +210,18 @@ a plugin with no `declaration.ts`, no `.env.example` — is printed while develo
210
210
  What it wires from the project:
211
211
 
212
212
  - **The port.** `PORT`, or 8080 — while developing, the next free one from there (`freePort`). `HOST`, loopback by
213
- default (`0.0.0.0` in a container). `PUBLIC_URL` is where people reach it, behind a proxy.
213
+ default (`0.0.0.0` in a container, or for a phone on the Wi-Fi — it prints every address to open, and while it
214
+ listens on loopback alone, how to open it).
215
+ - **HTTPS.** `TLS_CERT` and `TLS_KEY` in `.env`, the paths of a PEM certificate and its key — what `plitzi cert` makes
216
+ for this machine and its network addresses — unless `serverOptions.tls` gives one itself. Over plain http a phone's
217
+ browser gives a page no microphone, camera or clipboard. A network address the certificate does not name (the
218
+ router handed the machine a new one) is said at boot; this machine's mDNS name (`studio.local`), when the
219
+ certificate names it, is printed among the addresses, and outlives them.
220
+ - **Where people reach it** — the runtime's `publicUrl`: `PUBLIC_URL` behind a proxy; with `HOST` open to the network,
221
+ this machine's address there (what a tablet on the Wi-Fi opens, and an OAuth provider sends it back to); this
222
+ machine's loopback otherwise — `https` whenever it serves a certificate. Serving TLS itself, its
223
+ own requests to that address (a runtime calling its own MCP) connect to its listener here and take its certificate —
224
+ exactly that one — however local it is (`reachOwnServer`).
214
225
  - **Its plugins.** Every folder of `src/plugins` under its name in camelCase, built from its `index.ts` (or
215
226
  `index.tsx`, `pluginEntry`) and rendered
216
227
  on the server (`action: 'compile'`), with its server half (`functions/`); every plugin of `vendor/plugins`, run as it
@@ -221,7 +232,8 @@ What it wires from the project:
221
232
  - **Its files.** `public/` served as it is, `src/data/` read and never served (`dataDir`), the `kv` in `state/kv.json`
222
233
  (`createFileKv`), `PLITZI_SIGNING_SECRET` what `ctx.sign` signs with.
223
234
  - **Where it is.** `/health` answers with the space's permanent URL (or the cloud `name`), and `tmp/dev-server.json`
224
- records the name, the port and the URL for `plitzi page check`, `shot` and `visual`. A signal closes it (`closeOnSignals`).
235
+ records the name, the port, the URL this machine reaches it at (`https` with a certificate), its network addresses
236
+ and its `publicUrl` — for `plitzi page check`, `shot`, `verify` and `visual`. A signal closes it (`closeOnSignals`).
225
237
 
226
238
  While developing — `NODE_ENV` other than `production` — `devMode` and `devReload` are on, and:
227
239
 
@@ -496,7 +508,7 @@ createServer({
496
508
  });
497
509
  ```
498
510
 
499
- Static responses include `ETag`, `Last-Modified`, and `Cache-Control` headers. Subsequent requests with `If-None-Match` receive `304 Not Modified` when the file has not changed. JS, CSS, and font files are served with `Cache-Control: immutable`; all other assets use a 1-hour max-age.
511
+ Static responses include `ETag`, `Last-Modified`, and `Cache-Control` headers. Subsequent requests with `If-None-Match` receive `304 Not Modified` when the file has not changed. Under a versioned URL — the SDK bundle, the plugins, `static` mounts, fonts — JS, CSS and font files are served `immutable` and everything else with a 1-hour max-age. `publicDir` is the project's files by name, which it may rewrite while it runs: `no-cache`, asked about every time and answered `304` while unchanged.
500
512
 
501
513
  ### Images
502
514
 
@@ -1162,7 +1174,7 @@ and every refusal names a machine-readable `reason`, so a client can tell "renew
1162
1174
 
1163
1175
  | Config | Effect |
1164
1176
  |---|---|
1165
- | `cookie` | Name, domain, `SameSite`, `Secure`, the refresh path, the hint suffix. Defaults derive from the request host |
1177
+ | `cookie` | Name, domain, `SameSite`, `Secure`, the refresh path, the hint suffix. Defaults derive from the request host: this machine — loopback, a `.localhost` name, a private network address, its mDNS name (`studio.local`), as a phone on the Wi-Fi opens it over http — gets `SameSite=Lax` with no `Secure` and no `Domain`; any other host `SameSite=None; Secure`, shared across its sub-domains |
1166
1178
  | `api.password` | What a password has to be. `minLength` defaults to 8 (NIST SP 800-63B's floor); `validate` is where a breach-list lookup or a strength estimator goes. Applied wherever one is set — signing up, resetting, changing |
1167
1179
  | `api.rateLimit` | May this attempt proceed? **Defaults to an in-memory sliding window**, so no deployment is unthrottled by omission; supply one to put a single counter behind a whole fleet. Called before the password is checked, so a throttled attempt costs no hash |
1168
1180
  | `api.adminPermission` | The global capability the `/auth/admin/*` routes require. Default `userManage` |
@@ -1181,7 +1193,7 @@ write, `04-mysql` over one you do not.
1181
1193
 
1182
1194
  ### Cross-site request forgery
1183
1195
 
1184
- **On by default.** The session cookie defaults to `SameSite=None` off localhost — a space is embedded in an iframe
1196
+ **On by default.** The session cookie defaults to `SameSite=None` off this machine and its network — a space is embedded in an iframe
1185
1197
  on somebody else's domain — so the browser attaches it to requests another site caused. That is the attack, and
1186
1198
  `Lax` is what would otherwise prevent it.
1187
1199
 
@@ -22,6 +22,13 @@ export type SessionCookieParams = {
22
22
  * refuse one on a single-label host) and lax SameSite, because Secure + SameSite=None over plain http is dropped.
23
23
  */
24
24
  export declare const isLocalHost: (hostname: string) => boolean;
25
+ /**
26
+ * Whether the host is this machine as another device on its network reaches it: a private address, or the name
27
+ * mDNS gives it (`studio.local` — one label before `.local`, so a deployment's own `app.example.local` sub-domains,
28
+ * which share cookies across siblings over https, are not taken for one). Such a server is opened over plain http
29
+ * from a tablet, and gets the local cookie profile: a Secure cookie written over http is dropped.
30
+ */
31
+ export declare const isPrivateNetworkHost: (hostname: string) => boolean;
25
32
  /**
26
33
  * The last two labels of the host, so a session established on one sub-domain is carried by its siblings — an app
27
34
  * and the API it talks to are rarely the same host. Local and IP hosts get no domain at all: browsers refuse a
@@ -31,8 +38,9 @@ export declare const registrableDomain: (hostname: string) => string | undefined
31
38
  /**
32
39
  * Everything about how a session cookie is written, derived from the request host unless the deployment says
33
40
  * otherwise. The defaults are the ones a server on a real domain needs: a cookie shared across sub-domains, and
34
- * SameSite=None + Secure so it survives the app and the API being different hosts. A local server gets the
35
- * opposite, because Secure + SameSite=None over plain http is dropped by every browser.
41
+ * SameSite=None + Secure so it survives the app and the API being different hosts. A local server — this machine, or
42
+ * this machine as its network reaches it — gets the opposite, because Secure + SameSite=None over plain http is
43
+ * dropped by every browser.
36
44
  */
37
45
  export declare const sessionCookieParams: (hostname: string, config?: SSRAuthCookie) => SessionCookieParams;
38
46
  /** The readable companion cookie's name — the one a page is told to look for (`server.auth.sessionHintCookie`). */
@@ -1,4 +1,4 @@
1
- import { isIP } from "node:net";
1
+ import { BlockList, isIP } from "node:net";
2
2
  //#region src/core/auth/session.ts
3
3
  var LOCAL_HOSTS = [
4
4
  "localhost",
@@ -10,6 +10,30 @@ var LOCAL_HOSTS = [
10
10
  * refuse one on a single-label host) and lax SameSite, because Secure + SameSite=None over plain http is dropped.
11
11
  */
12
12
  var isLocalHost = (hostname) => LOCAL_HOSTS.includes(hostname) || hostname.endsWith(".localhost");
13
+ /**
14
+ * The addresses a home or office network hands out — RFC 1918, link-local, the shared range a mesh VPN (Tailscale)
15
+ * gives each device, IPv6 unique- and link-local — which a phone on the same Wi-Fi opens a development server on.
16
+ */
17
+ var PRIVATE_NETWORK = new BlockList();
18
+ PRIVATE_NETWORK.addSubnet("10.0.0.0", 8);
19
+ PRIVATE_NETWORK.addSubnet("172.16.0.0", 12);
20
+ PRIVATE_NETWORK.addSubnet("192.168.0.0", 16);
21
+ PRIVATE_NETWORK.addSubnet("169.254.0.0", 16);
22
+ PRIVATE_NETWORK.addSubnet("100.64.0.0", 10);
23
+ PRIVATE_NETWORK.addSubnet("fc00::", 7, "ipv6");
24
+ PRIVATE_NETWORK.addSubnet("fe80::", 10, "ipv6");
25
+ /**
26
+ * Whether the host is this machine as another device on its network reaches it: a private address, or the name
27
+ * mDNS gives it (`studio.local` — one label before `.local`, so a deployment's own `app.example.local` sub-domains,
28
+ * which share cookies across siblings over https, are not taken for one). Such a server is opened over plain http
29
+ * from a tablet, and gets the local cookie profile: a Secure cookie written over http is dropped.
30
+ */
31
+ var isPrivateNetworkHost = (hostname) => {
32
+ const address = hostname.replace(/^\[(.*)\]$/, "$1");
33
+ const family = isIP(address);
34
+ if (family === 0) return /^[^.]+\.local$/.test(hostname);
35
+ return PRIVATE_NETWORK.check(address, family === 4 ? "ipv4" : "ipv6");
36
+ };
13
37
  var isIpAddress = (hostname) => isIP(hostname) !== 0;
14
38
  /**
15
39
  * The last two labels of the host, so a session established on one sub-domain is carried by its siblings — an app
@@ -17,7 +41,7 @@ var isIpAddress = (hostname) => isIP(hostname) !== 0;
17
41
  * Domain on a single-label host, and one written for an address would never be sent back to it.
18
42
  */
19
43
  var registrableDomain = (hostname) => {
20
- if (isLocalHost(hostname) || isIpAddress(hostname)) return;
44
+ if (isLocalHost(hostname) || isIpAddress(hostname) || isPrivateNetworkHost(hostname)) return;
21
45
  const match = /([^.]+\.[^.]+)$/.exec(hostname);
22
46
  return match ? `.${match[1]}` : void 0;
23
47
  };
@@ -28,11 +52,12 @@ var resolve = (value, hostname, fallback) => {
28
52
  /**
29
53
  * Everything about how a session cookie is written, derived from the request host unless the deployment says
30
54
  * otherwise. The defaults are the ones a server on a real domain needs: a cookie shared across sub-domains, and
31
- * SameSite=None + Secure so it survives the app and the API being different hosts. A local server gets the
32
- * opposite, because Secure + SameSite=None over plain http is dropped by every browser.
55
+ * SameSite=None + Secure so it survives the app and the API being different hosts. A local server — this machine, or
56
+ * this machine as its network reaches it — gets the opposite, because Secure + SameSite=None over plain http is
57
+ * dropped by every browser.
33
58
  */
34
59
  var sessionCookieParams = (hostname, config = {}) => {
35
- const local = isLocalHost(hostname);
60
+ const local = isLocalHost(hostname) || isPrivateNetworkHost(hostname);
36
61
  return {
37
62
  name: resolve(config.name, hostname, "plitzi_session"),
38
63
  domain: resolve(config.domain, hostname, registrableDomain(hostname)),
@@ -209,4 +234,4 @@ var createSessionCookies = (config) => ({
209
234
  }
210
235
  });
211
236
  //#endregion
212
- export { appendCookies, clearFlowCookie, clearSessionCookies, createSessionCookies, isLocalHost, parseSessionHint, readFlowCookie, readRefreshToken, readRenewalGuard, readSessionHint, readSessionToken, registrableDomain, sessionCookieParams, sessionHintCookieName, sessionHintValue, sessionReturnTarget, writeFlowCookie, writeRenewalGuard, writeSessionCookies };
237
+ export { appendCookies, clearFlowCookie, clearSessionCookies, createSessionCookies, isLocalHost, isPrivateNetworkHost, parseSessionHint, readFlowCookie, readRefreshToken, readRenewalGuard, readSessionHint, readSessionToken, registrableDomain, sessionCookieParams, sessionHintCookieName, sessionHintValue, sessionReturnTarget, writeFlowCookie, writeRenewalGuard, writeSessionCookies };
@@ -1,3 +1,4 @@
1
+ import { requestAuthority } from "../../requestParser.js";
1
2
  import { readRenewalGuard, readSessionHint, readSessionToken, writeRenewalGuard } from "../../auth/session.js";
2
3
  import { isDocumentNavigation, renewForNavigation } from "../../auth/renewal.js";
3
4
  //#region src/core/http/stages/sessionRenewal.ts
@@ -9,7 +10,7 @@ var nowInSeconds = () => Math.floor(Date.now() / 1e3);
9
10
  * read off the authority rather than taken whole, so a forged `Host` cannot put anything but digits after the name.
10
11
  */
11
12
  var pageUrl = (req) => {
12
- const authority = req.headers[":authority"] ?? req.headers.host ?? "";
13
+ const authority = requestAuthority(req);
13
14
  const port = /:(\d{1,5})$/u.exec(authority)?.[1];
14
15
  return `${req.protocol}://${req.hostname}${port ? `:${port}` : ""}${req.path}${req.search}`;
15
16
  };
@@ -1,5 +1,5 @@
1
- import { readCookies } from "../../auth/credentials.js";
2
1
  import { requestOrigin } from "../../requestParser.js";
2
+ import { readCookies } from "../../auth/credentials.js";
3
3
  import { safeRedirectTarget } from "../navigation.js";
4
4
  import { appendCookies, writeSessionCookies } from "../../auth/session.js";
5
5
  import { createHash, randomBytes } from "node:crypto";
@@ -1,7 +1,7 @@
1
1
  import { resolveSdkAssetsDir } from "../../sdkAssets.js";
2
2
  import { serveStatic } from "../../staticFiles.js";
3
3
  //#region src/core/http/stages/static.ts
4
- var publicDirStage = (ctx) => ctx.config.publicDir ? serveStatic(ctx.req, ctx.res, ctx.config.publicDir) : false;
4
+ var publicDirStage = (ctx) => ctx.config.publicDir ? serveStatic(ctx.req, ctx.res, ctx.config.publicDir, "revalidate") : false;
5
5
  var wellKnownStage = (ctx) => {
6
6
  if (!ctx.req.path.startsWith("/.well-known/")) return false;
7
7
  ctx.res.setStatus(404);
@@ -1,4 +1,13 @@
1
1
  export declare const MIME_TYPES: Record<string, string>;
2
2
  export declare const IMMUTABLE_EXTS: Set<string>;
3
3
  export declare const getMimeType: (filePath: string) => string;
4
- export declare const getCacheControl: (filePath: string) => string;
4
+ /**
5
+ * How long a browser may keep a file without asking again.
6
+ *
7
+ * `versioned` is for a directory whose URLs change with their content — the SDK bundle and the plugins, addressed
8
+ * with `?v=` — where a script or a stylesheet never changes under its URL and is kept for a year. `revalidate` is for
9
+ * files a project keeps by name and may rewrite while it runs (`public/`): kept, but asked about every time, which
10
+ * the `ETag` answers with an empty `304` when nothing changed.
11
+ */
12
+ export type StaticCachePolicy = 'versioned' | 'revalidate';
13
+ export declare const getCacheControl: (filePath: string, policy?: StaticCachePolicy) => string;
@@ -33,7 +33,8 @@ var IMMUTABLE_EXTS = /* @__PURE__ */ new Set([
33
33
  var getMimeType = (filePath) => {
34
34
  return MIME_TYPES[filePath.slice(filePath.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream";
35
35
  };
36
- var getCacheControl = (filePath) => {
36
+ var getCacheControl = (filePath, policy = "versioned") => {
37
+ if (policy === "revalidate") return "no-cache";
37
38
  const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
38
39
  return IMMUTABLE_EXTS.has(ext) ? "public, max-age=31536000, immutable" : "public, max-age=3600";
39
40
  };
@@ -1,3 +1,4 @@
1
+ import { requestAuthority } from "../requestParser.js";
1
2
  import { renderConsentPage } from "./consentPage.js";
2
3
  import { dropPending, getClient, getPending, putCode, putPending } from "./records.js";
3
4
  import { issueContextFor } from "./issueContext.js";
@@ -49,7 +50,7 @@ var renderConsent = (res, view) => {
49
50
  * the server is mounted under a prefix, which resolving `/authorize` against it would drop. The same joining the
50
51
  * discovery document publishes the endpoint by.
51
52
  */
52
- var authorizeEndpoint = (config, req) => new URL(`${(config.issuer ?? `https://${req.headers.host ?? ""}`).replace(/\/+$/u, "")}${AUTHORIZE_PATH}`);
53
+ var authorizeEndpoint = (config, req) => new URL(`${(config.issuer ?? `https://${requestAuthority(req)}`).replace(/\/+$/u, "")}${AUTHORIZE_PATH}`);
53
54
  /** The same endpoint, as the path the grant screen's form posts to — this server's own origin, whatever it is. */
54
55
  var authorizeAction = (config) => config.issuer ? `${new URL(config.issuer).pathname.replace(/\/+$/u, "")}${AUTHORIZE_PATH}` : AUTHORIZE_PATH;
55
56
  var authorizeUrl = (config, req, params) => {
@@ -1,6 +1,12 @@
1
1
  import { SSRRequest } from '@plitzi/sdk-shared';
2
2
  import { IncomingMessage } from 'node:http';
3
3
  export declare const parseRequest: (raw: IncomingMessage) => SSRRequest;
4
+ /**
5
+ * The host the request was addressed to, port included (`localhost:8080`): HTTP/2's `:authority` — an HTTP/2
6
+ * request has no `Host` header at all — else `Host`. What names this origin's cookies (`debugCookieName` and the
7
+ * rest carry the port), so a server reached over HTTP/2 must read it here, never `req.headers.host`.
8
+ */
9
+ export declare const requestAuthority: (req: Pick<SSRRequest, "headers">) => string;
4
10
  /** The public origin the request was addressed to (proxy-aware via x-forwarded-proto, port included), or an empty
5
11
  * string when the request carries no usable authority. */
6
12
  export declare const requestOrigin: (req: SSRRequest) => string;
@@ -34,10 +34,16 @@ var parseRequest = (raw) => {
34
34
  };
35
35
  };
36
36
  var AUTHORITY_RE = /^[a-zA-Z0-9.-]{1,253}(?::\d{1,5})?$/u;
37
+ /**
38
+ * The host the request was addressed to, port included (`localhost:8080`): HTTP/2's `:authority` — an HTTP/2
39
+ * request has no `Host` header at all — else `Host`. What names this origin's cookies (`debugCookieName` and the
40
+ * rest carry the port), so a server reached over HTTP/2 must read it here, never `req.headers.host`.
41
+ */
42
+ var requestAuthority = (req) => req.headers[":authority"] ?? req.headers["host"] ?? "";
37
43
  /** The public origin the request was addressed to (proxy-aware via x-forwarded-proto, port included), or an empty
38
44
  * string when the request carries no usable authority. */
39
45
  var requestOrigin = (req) => {
40
- const authority = req.headers[":authority"] ?? req.headers["host"] ?? "";
46
+ const authority = requestAuthority(req);
41
47
  return AUTHORITY_RE.test(authority) ? `${req.protocol}://${authority}` : "";
42
48
  };
43
49
  var headerValue = (value) => (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
@@ -70,4 +76,4 @@ var readRawBytes = (raw) => new Promise((resolve, reject) => {
70
76
  });
71
77
  var readRawBody = async (raw) => (await readRawBytes(raw)).toString("utf8");
72
78
  //#endregion
73
- export { clientIp, forwardedIp, parseRequest, readRawBody, readRawBytes, requestOrigin };
79
+ export { clientIp, forwardedIp, parseRequest, readRawBody, readRawBytes, requestAuthority, requestOrigin };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,9 +1,9 @@
1
1
  import { connectorLookupsOf } from "../configSeam.js";
2
+ import { clientIp, readRawBody } from "../requestParser.js";
2
3
  import { handleActionCall } from "../../modules/actions/transport/callHandler.js";
3
4
  import { handleActionCatalog } from "../../modules/actions/transport/catalogHandler.js";
4
5
  import { handleActionWebhook } from "../../modules/actions/transport/webhookHandler.js";
5
6
  import { handleActionCancel } from "../../modules/actions/transport/cancelHandler.js";
6
- import { clientIp, readRawBody } from "../requestParser.js";
7
7
  import { handleAction } from "../../modules/actions/connectorWrite.js";
8
8
  //#region src/core/services/action.ts
9
9
  var LINEAGE_HEADER = "x-plitzi-action-lineage";
@@ -1,5 +1,5 @@
1
- import { handleRealtimePublish, handleRealtimeSubscribe } from "../../modules/realtime/handlers.js";
2
1
  import { readRawBody } from "../requestParser.js";
2
+ import { handleRealtimePublish, handleRealtimeSubscribe } from "../../modules/realtime/handlers.js";
3
3
  import { handleRealtimeSocket } from "../../modules/realtime/socket.js";
4
4
  //#region src/core/services/realtime.ts
5
5
  /**
@@ -1,2 +1,3 @@
1
+ import { StaticCachePolicy } from './mimeTypes';
1
2
  import { SSRRequest, SSRResponseHelpers } from '@plitzi/sdk-shared';
2
- export declare const serveStatic: (req: SSRRequest, res: SSRResponseHelpers, rootDir: string) => boolean;
3
+ export declare const serveStatic: (req: SSRRequest, res: SSRResponseHelpers, rootDir: string, cache?: StaticCachePolicy) => boolean;
@@ -6,11 +6,11 @@ import fs from "node:fs";
6
6
  var compressedFiles = new CompressedFileCache(16777216);
7
7
  var isText = (mimeType) => /^text\/|javascript|json|xml|svg/.test(mimeType);
8
8
  var buildEtag = (stat) => `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
9
- var serveFile = (req, res, filePath, stat) => {
9
+ var serveFile = (req, res, filePath, stat, cache) => {
10
10
  const etag = buildEtag(stat);
11
11
  const ifNoneMatch = req.headers["if-none-match"];
12
12
  res.setHeader("ETag", etag);
13
- res.setHeader("Cache-Control", getCacheControl(filePath));
13
+ res.setHeader("Cache-Control", getCacheControl(filePath, cache));
14
14
  res.setHeader("Last-Modified", stat.mtime.toUTCString());
15
15
  if (ifNoneMatch === etag) {
16
16
  res.setStatus(304);
@@ -27,7 +27,7 @@ var serveFile = (req, res, filePath, stat) => {
27
27
  res.send(fs.readFileSync(filePath));
28
28
  return true;
29
29
  };
30
- var serveStatic = (req, res, rootDir) => {
30
+ var serveStatic = (req, res, rootDir, cache = "versioned") => {
31
31
  const relative = req.path.replace(/^\/+/, "");
32
32
  const filePath = path.resolve(rootDir, relative);
33
33
  const resolvedRoot = path.resolve(rootDir);
@@ -41,9 +41,9 @@ var serveStatic = (req, res, rootDir) => {
41
41
  if (stat.isDirectory()) {
42
42
  const indexPath = path.join(filePath, "index.html");
43
43
  if (!fs.existsSync(indexPath)) return false;
44
- return serveFile(req, res, indexPath, fs.statSync(indexPath));
44
+ return serveFile(req, res, indexPath, fs.statSync(indexPath), cache);
45
45
  }
46
- return serveFile(req, res, filePath, stat);
46
+ return serveFile(req, res, filePath, stat, cache);
47
47
  };
48
48
  //#endregion
49
49
  export { serveStatic };
@@ -39,6 +39,7 @@ export declare const buildHtmlCacheKey: (accessToken: string | undefined, spaceI
39
39
  headers: {
40
40
  cookie?: string;
41
41
  host?: string;
42
+ ":authority"?: string;
42
43
  };
43
44
  }) => string;
44
45
  /**
@@ -1,3 +1,4 @@
1
+ import { requestAuthority } from "../../core/requestParser.js";
1
2
  import { ssrPaintedCookieName } from "../paintedCookie.js";
2
3
  import { readCookie } from "../readCookie.js";
3
4
  import { flagsCookieName } from "@plitzi/sdk-shared/flags";
@@ -54,9 +55,9 @@ var buildHtmlCacheKey = (accessToken = "anonymous", spaceId, environment, revisi
54
55
  revision: String(revision),
55
56
  flags: flagsVersionOf(req),
56
57
  theme: themeFromCookies(req.headers.cookie) ?? "",
57
- painted: readCookie(req.headers.cookie, ssrPaintedCookieName(req.headers.host)) ?? "",
58
- debugHidden: readCookie(req.headers.cookie, debugCookieName(req.headers.host)) === "false" ? "debug-off" : "",
59
- forcedFlags: readCookie(req.headers.cookie, flagsCookieName(req.headers.host)) ?? "",
58
+ painted: readCookie(req.headers.cookie, ssrPaintedCookieName(requestAuthority(req))) ?? "",
59
+ debugHidden: readCookie(req.headers.cookie, debugCookieName(requestAuthority(req))) === "false" ? "debug-off" : "",
60
+ forcedFlags: readCookie(req.headers.cookie, flagsCookieName(requestAuthority(req))) ?? "",
60
61
  hostname: req.hostname,
61
62
  path: req.path,
62
63
  search: req.search
@@ -13,6 +13,7 @@ export declare const forcedFlagsFor: (req: {
13
13
  headers: {
14
14
  cookie?: string;
15
15
  host?: string;
16
+ ":authority"?: string;
16
17
  };
17
18
  }, authorized: boolean | (() => Promise<boolean>)) => Promise<Record<string, boolean> | undefined>;
18
19
  /** Both layers this server answers for, for one request. */
@@ -20,6 +21,7 @@ export declare const requestFlagOverrides: (config: Pick<SSRServerConfig, "flags
20
21
  headers: {
21
22
  cookie?: string;
22
23
  host?: string;
24
+ ":authority"?: string;
23
25
  };
24
26
  }, space: {
25
27
  spaceId: number;
@@ -1,3 +1,4 @@
1
+ import { requestAuthority } from "../core/requestParser.js";
1
2
  import { forcedFlagsFromCookies } from "@plitzi/sdk-shared/flags";
2
3
  //#region src/helpers/flagOverrides.ts
3
4
  /** The deployment's own layer for one space: `config.flags`, asked per space when it is a function. */
@@ -8,7 +9,7 @@ var serverFlagsFor = (config, space) => typeof config.flags === "function" ? con
8
9
  * mean reading the space.
9
10
  */
10
11
  var forcedFlagsFor = async (req, authorized) => {
11
- const forced = forcedFlagsFromCookies(req.headers.cookie, req.headers.host);
12
+ const forced = forcedFlagsFromCookies(req.headers.cookie, requestAuthority(req));
12
13
  if (Object.keys(forced).length === 0) return;
13
14
  return (typeof authorized === "boolean" ? authorized : await authorized()) ? forced : void 0;
14
15
  };
@@ -49,7 +49,7 @@ export type ServeProjectOptions = ServeProjectBase & ({
49
49
  /** The project's server, running. */
50
50
  export type ServedProject = {
51
51
  server: SSRServer;
52
- /** Where it listens, as `tmp/dev-server.json` records it: `http://127.0.0.1:<port>`. */
52
+ /** Where this machine reaches it, as `tmp/dev-server.json` records it: `http://127.0.0.1:<port>`, `https` with a certificate. */
53
53
  url: string;
54
54
  /** Stops watching, closes the server and the runtime — what a signal does, for a caller that stops it itself. */
55
55
  close: () => Promise<void>;
@@ -62,7 +62,8 @@ export type ServedProject = {
62
62
  * space authored. A refusal of any of the three is printed as it is — the message is the whole report, every problem
63
63
  * and its fix, and a stack would only point inside the SDK — and the process exits with 1. What it serves:
64
64
  *
65
- * - the port: `PORT`, or 8080 — the next free one from there while developing; `HOST`, loopback by default;
65
+ * - the port: `PORT`, or 8080 — the next free one from there while developing; `HOST`, loopback by default; HTTPS
66
+ * with `TLS_CERT` and `TLS_KEY` (`plitzi cert`), unless the project's options give `tls` themselves;
66
67
  * - its plugins: every folder of `src/plugins` built from its source and server-rendered, every one of `vendor/plugins`
67
68
  * as it was built, each with its server half;
68
69
  * - its code: `src/functions/`, and the space's runtime (`src/runtime/` — compiled under `dist/` when the server runs
@@ -1,23 +1,27 @@
1
1
  import { consoleLogger } from "../../helpers/serverLog.js";
2
2
  import { isFleetWorker } from "../../core/server/fleet/role.js";
3
3
  import { loadFunctions } from "../functions/load.js";
4
- import { createServer } from "../../core/createServer.js";
4
+ import { createServer as createServer$1 } from "../../core/createServer.js";
5
5
  import { closeOnSignals } from "../../core/server/closeOnSignals.js";
6
6
  import { freePort } from "../../core/freePort.js";
7
7
  import { createCloudAdapters } from "../../adapters/cloudAdapters.js";
8
8
  import { createJsonAdapters } from "../../adapters/jsonAdapters.js";
9
9
  import { createFileKv } from "../actions/runtime/fileKv.js";
10
10
  import { credentialsFromEnv } from "../actions/runtime/credentials.js";
11
+ import { reachOwnServer } from "../runtime/inside.js";
11
12
  import { loadRuntime, loadRuntimeModule } from "../runtime/bundle.js";
12
13
  import { serveRuntime } from "../runtime/stages.js";
13
14
  import { projectPlugins, watchProjectPlugins } from "./plugins.js";
14
15
  import { createSpaceFailure } from "./spaceFailure.js";
15
16
  import { watchSpace } from "./watchSpace.js";
16
17
  import { fleetKv } from "../../core/server/fleet/stores.js";
17
- import { networkInterfaces } from "node:os";
18
+ import { X509Certificate } from "node:crypto";
19
+ import { hostname, networkInterfaces } from "node:os";
20
+ import { isIP } from "node:net";
18
21
  import path from "node:path";
19
22
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
23
  import { assertProjectLayout, layoutFindingText } from "@plitzi/sdk-shared/project/layout";
24
+ import { localNetworkName, networkAddresses } from "@plitzi/sdk-shared/project/network";
21
25
  import { DATA_DIR, DEV_SERVER_FILE, FUNCTIONS_DIR, KV_FILE, PROJECT_TMP, PUBLIC_DIR, RUNTIME_BUNDLE, RUNTIME_ENTRY } from "@plitzi/sdk-shared/project/paths";
22
26
  import { projectModule, projectRoot } from "@plitzi/sdk-shared/project/root";
23
27
  import { EMPTY_SCHEMA } from "@plitzi/sdk-shared/schema/schemaConstants";
@@ -68,10 +72,53 @@ var LOOPBACK = /* @__PURE__ */ new Set([
68
72
  "::1",
69
73
  "localhost"
70
74
  ]);
75
+ var ANY_ADDRESS = /* @__PURE__ */ new Set(["0.0.0.0", "::"]);
76
+ /** An address as a URL writes it: an IPv6 one in brackets. */
77
+ var urlHost = (address) => address.includes(":") ? `[${address}]` : address;
78
+ /** Where this machine reaches a server listening on `host`: its loopback, unless it listens on one address only. */
79
+ var localAddress = (host) => host === "::1" ? host : LOOPBACK.has(host) || ANY_ADDRESS.has(host) ? "127.0.0.1" : host;
71
80
  /** Where another device reaches a server listening on `host`: this machine's own network addresses, when it is open. */
72
- var networkUrls = (host, port) => {
81
+ var addressesFor = (host) => {
73
82
  if (LOOPBACK.has(host)) return [];
74
- return (host === "0.0.0.0" || host === "::" ? Object.values(networkInterfaces()).flat().flatMap((entry) => entry && entry.family === "IPv4" && !entry.internal ? [entry.address] : []) : [host]).map((address) => `http://${address}:${String(port)}/`);
83
+ return ANY_ADDRESS.has(host) ? networkAddresses(networkInterfaces()) : [host];
84
+ };
85
+ /**
86
+ * HTTPS with no code of the project's: `TLS_CERT` and `TLS_KEY` in `.env`, the PEM certificate and its key — what
87
+ * `plitzi cert` makes, for a tablet on the Wi-Fi, whose browser gives a page served over http no microphone, camera,
88
+ * clipboard or service worker. Relative to the project's root.
89
+ */
90
+ var tlsFromEnv = (root) => {
91
+ const { TLS_CERT: cert, TLS_KEY: key } = process.env;
92
+ if (!cert && !key) return;
93
+ if (!cert || !key) throw new Error("Set both TLS_CERT and TLS_KEY in .env — the certificate and its key — or neither.");
94
+ const read = (name, file) => {
95
+ try {
96
+ return readFileSync(path.resolve(root, file));
97
+ } catch {
98
+ throw new Error(`${name} in .env is "${file}", which cannot be read — \`plitzi cert\` makes the pair.`);
99
+ }
100
+ };
101
+ return {
102
+ cert: read("TLS_CERT", cert),
103
+ key: read("TLS_KEY", key)
104
+ };
105
+ };
106
+ /**
107
+ * What another device opens this server by, its certificate considered: every network address, and this machine's
108
+ * mDNS name when the certificate names it. The addresses the certificate does not name are said apart — a device that
109
+ * opens one is refused the page, which happens when the router hands this machine a new address.
110
+ */
111
+ var reachedBy = (addresses, tls) => {
112
+ if (!tls || addresses.length === 0) return {
113
+ names: addresses,
114
+ unnamed: []
115
+ };
116
+ const certificate = new X509Certificate(tls.cert);
117
+ const name = localNetworkName(hostname());
118
+ return {
119
+ names: [...addresses, ...certificate.checkHost(name) ? [name] : []],
120
+ unnamed: addresses.filter((address) => (isIP(address) ? certificate.checkIP(address) : certificate.checkHost(address)) === void 0)
121
+ };
75
122
  };
76
123
  var actionLookups = (actions, connectors, credentials) => ({
77
124
  getAction: (_spaceId, actionId) => Promise.resolve(actions.find((entry) => entry.id === actionId)),
@@ -118,8 +165,24 @@ var startProject = async (options) => {
118
165
  if (developing) for (const warning of layout) console.warn(`[layout] ${layoutFindingText(warning)}`);
119
166
  const host = process.env.HOST ?? "127.0.0.1";
120
167
  const port = process.env.PORT ? Number(process.env.PORT) : developing ? await freePort(8080, host) : 8080;
121
- /** Where people reach this server: `PUBLIC_URL` behind a proxy, its own address otherwise. */
122
- const publicUrl = (process.env.PUBLIC_URL ?? `http://127.0.0.1:${String(port)}`).replace(/\/+$/, "");
168
+ const tls = serverOptions.tls ?? tlsFromEnv(root);
169
+ const scheme = tls ? "https" : "http";
170
+ const localUrl = `${scheme}://${urlHost(localAddress(host))}:${String(port)}`;
171
+ const reached = reachedBy(addressesFor(host), tls);
172
+ const network = reached.names.map((address) => `${scheme}://${urlHost(address)}:${String(port)}`);
173
+ /**
174
+ * Where people reach this server: `PUBLIC_URL` behind a proxy; open to the network, the address another device uses
175
+ * — what an OAuth provider sends a tablet back to; this machine's otherwise.
176
+ */
177
+ const publicUrl = (process.env.PUBLIC_URL ?? network.at(0) ?? localUrl).replace(/\/+$/, "");
178
+ if (tls && process.env.PUBLIC_URL === void 0) await reachOwnServer({
179
+ publicUrl,
180
+ listener: {
181
+ host: localAddress(host),
182
+ port
183
+ },
184
+ cert: tls.cert
185
+ });
123
186
  const { space, failure, name } = await spaceToServe(options, developing, root);
124
187
  const plugins = await projectPlugins(root);
125
188
  const held = options.space ? { ...space ? { documents: {
@@ -159,10 +222,11 @@ var startProject = async (options) => {
159
222
  });
160
223
  return keptKv;
161
224
  });
162
- const server = createServer({
225
+ const server = createServer$1({
163
226
  logLevel: process.argv.includes("--verbose") ? "info" : "warn",
164
227
  logger: consoleLogger,
165
228
  ...serverOptions,
229
+ ...tls ? { tls } : {},
166
230
  port,
167
231
  devMode: serverOptions.devMode ?? developing,
168
232
  devReload: serverOptions.devReload ?? serverOptions.devMode ?? developing,
@@ -183,17 +247,19 @@ var startProject = async (options) => {
183
247
  }
184
248
  }, { preAuth: [spaceFailure.stage, ...runtime ? [runtime.stage] : []] });
185
249
  await server.listen(port, host);
186
- const url = `http://127.0.0.1:${String(port)}`;
187
250
  if (speaks) {
188
251
  mkdirSync(path.join(root, PROJECT_TMP), { recursive: true });
189
252
  writeFileSync(path.join(root, DEV_SERVER_FILE), `${JSON.stringify({
190
253
  name,
191
254
  port,
192
- url
255
+ url: localUrl,
256
+ network,
257
+ publicUrl
193
258
  }, null, 2)}\n`);
194
- console.log(`pages on ${url}/`);
195
- const network = networkUrls(host, port);
196
- if (network.length > 0) console.log(`also on ${network.join(", ")} — anyone on this network can open it (HOST=${host})`);
259
+ console.log(`pages on ${localUrl}/`);
260
+ if (network.length > 0) console.log(`also on ${network.map((address) => `${address}/`).join(", ")} — anyone on this network can open it (HOST=${host})`);
261
+ else if (developing && LOOPBACK.has(host)) console.log("only this machine opens it: HOST=0.0.0.0 in .env opens it to your network (a phone on the Wi-Fi)");
262
+ if (reached.unnamed.length > 0) console.warn(`[tls] the certificate does not name ${reached.unnamed.join(", ")}: a device that opens it there is refused — \`plitzi cert\` makes one for this machine as it is now`);
197
263
  }
198
264
  const watching = developing ? [...held ? [watchSpace(root, (authored) => {
199
265
  held.documents = authored;
@@ -209,7 +275,7 @@ var startProject = async (options) => {
209
275
  const stopListening = closeOnSignals({ close });
210
276
  return {
211
277
  server,
212
- url,
278
+ url: localUrl,
213
279
  close: async () => {
214
280
  stopListening();
215
281
  await close();
@@ -224,7 +290,8 @@ var startProject = async (options) => {
224
290
  * space authored. A refusal of any of the three is printed as it is — the message is the whole report, every problem
225
291
  * and its fix, and a stack would only point inside the SDK — and the process exits with 1. What it serves:
226
292
  *
227
- * - the port: `PORT`, or 8080 — the next free one from there while developing; `HOST`, loopback by default;
293
+ * - the port: `PORT`, or 8080 — the next free one from there while developing; `HOST`, loopback by default; HTTPS
294
+ * with `TLS_CERT` and `TLS_KEY` (`plitzi cert`), unless the project's options give `tls` themselves;
228
295
  * - its plugins: every folder of `src/plugins` built from its source and server-rendered, every one of `vendor/plugins`
229
296
  * as it was built, each with its server half;
230
297
  * - its code: `src/functions/`, and the space's runtime (`src/runtime/` — compiled under `dist/` when the server runs
@@ -1,6 +1,6 @@
1
+ import { requestOrigin } from "../../core/requestParser.js";
1
2
  import { warnRealtime } from "./failure.js";
2
3
  import { admit, answer, connectionFor, publishFrom } from "./handlers.js";
3
- import { requestOrigin } from "../../core/requestParser.js";
4
4
  import { frameText } from "../../helpers/wsFrame.js";
5
5
  //#region src/modules/realtime/socket.ts
6
6
  /** The largest frame a page may send: well above the largest message a channel may declare, and bounded. */
@@ -1,4 +1,5 @@
1
1
  import { serverLog } from "../../helpers/serverLog.js";
2
+ import { requestAuthority } from "../../core/requestParser.js";
2
3
  import { requestFlagOverrides } from "../../helpers/flagOverrides.js";
3
4
  import { resolveDebugAuthorization } from "../../helpers/debugAuthorization.js";
4
5
  import { DEFAULT_TTL_MS } from "../../helpers/cache/defaults.js";
@@ -114,7 +115,7 @@ var handleRsc = async (req, res, config, _pluginManager, cache, signal) => {
114
115
  * A tester forcing flags from the dev tools sees data for features nobody else has on — and must never be handed a
115
116
  * slice resolved without them. Their refreshes go around the cache both ways, like a draft's.
116
117
  */
117
- const forcingFlags = Object.keys(forcedFlagsFromCookies(req.headers.cookie, req.headers.host)).length > 0;
118
+ const forcingFlags = Object.keys(forcedFlagsFromCookies(req.headers.cookie, requestAuthority(req))).length > 0;
118
119
  const uncached = previewing || forcingFlags;
119
120
  const ttlMs = config.rsc?.cacheTtlMs ?? DEFAULT_TTL_MS.rsc;
120
121
  const isAuthenticated = !!req.ctx.user;
@@ -0,0 +1 @@
1
+ export {};
@@ -13,3 +13,24 @@ export declare const reachSpaceInside: ({ publicUrl, insideUrl }: {
13
13
  publicUrl: string;
14
14
  insideUrl: string;
15
15
  }) => Promise<void>;
16
+ export type OwnServer = {
17
+ /** Where people reach this server — the address its code writes and fetches. */
18
+ publicUrl: string;
19
+ /** Where this machine reaches it: the address and port it listens on. */
20
+ listener: {
21
+ host: string;
22
+ port: number;
23
+ };
24
+ /** The certificate it serves, PEM: the one a connection to it has to present to be taken as this server. */
25
+ cert: string | Buffer;
26
+ };
27
+ /**
28
+ * A self-hosted server serving TLS itself, reached from its own process — the runtime calling its own MCP, a function
29
+ * calling a route of its own space: every request to its public address connects to its listener on this machine.
30
+ *
31
+ * Its certificate is usually a local one (mkcert, for a tablet on the Wi-Fi), which the browsers on that network were
32
+ * told to trust and Node was not, so these requests failed whatever the address said. They are taken when the peer
33
+ * presents exactly this server's certificate — its fingerprint, not a chain — and refused otherwise; nothing else the
34
+ * process reaches is verified any less.
35
+ */
36
+ export declare const reachOwnServer: ({ publicUrl, listener, cert }: OwnServer) => Promise<void>;
@@ -1,4 +1,6 @@
1
+ import { X509Certificate } from "node:crypto";
1
2
  import net from "node:net";
3
+ import tls from "node:tls";
2
4
  //#region src/modules/runtime/inside.ts
3
5
  /** Headers as a dispatch carries them — a record, name/value pairs flattened into one list, or pairs — plus one more. */
4
6
  var withHeader = (headers, name, value) => {
@@ -12,6 +14,12 @@ var withHeader = (headers, name, value) => {
12
14
  };
13
15
  };
14
16
  /**
17
+ * How every OTHER host is reached once one of these dispatchers is the process's: as Node's own `fetch` reaches it,
18
+ * over HTTP/1.1. An undici connector offers HTTP/2 by default, and an answer that came back over h2 reached Node's
19
+ * `fetch` without its `content-encoding` — so nothing decoded it, and every API that compresses read as Brotli bytes.
20
+ */
21
+ var elsewhere = (undici) => undici.buildConnector({ allowH2: false });
22
+ /**
15
23
  * The space's own public address, reached from inside the network this runtime runs in: every request this process
16
24
  * makes to the space's host — `fetch` and `WebSocket` alike, which share Node's dispatcher — connects to `insideUrl`
17
25
  * instead, over plain HTTP, the space's host and the public protocol kept (`X-Forwarded-Proto`). It is the path a
@@ -26,10 +34,11 @@ var reachSpaceInside = async ({ publicUrl, insideUrl }) => {
26
34
  const outside = new URL(publicUrl);
27
35
  const inside = new URL(insideUrl);
28
36
  if (inside.protocol !== "http:") throw new Error(`A space is reached from inside over plain HTTP, not ${inside.protocol} (${insideUrl})`);
29
- const { Agent, buildConnector, setGlobalDispatcher } = await import("undici");
37
+ const undici = await import("undici");
38
+ const { Agent, setGlobalDispatcher } = undici;
30
39
  const port = Number(inside.port || 80);
31
40
  const protocol = outside.protocol.replace(":", "");
32
- const connect = buildConnector({});
41
+ const connect = elsewhere(undici);
33
42
  setGlobalDispatcher(new Agent({ connect: (options, callback) => {
34
43
  if (options.hostname !== outside.hostname) {
35
44
  connect(options, callback);
@@ -43,5 +52,44 @@ var reachSpaceInside = async ({ publicUrl, insideUrl }) => {
43
52
  headers: withHeader(options.headers, "x-forwarded-proto", protocol)
44
53
  } : options, handler)));
45
54
  };
55
+ /**
56
+ * A self-hosted server serving TLS itself, reached from its own process — the runtime calling its own MCP, a function
57
+ * calling a route of its own space: every request to its public address connects to its listener on this machine.
58
+ *
59
+ * Its certificate is usually a local one (mkcert, for a tablet on the Wi-Fi), which the browsers on that network were
60
+ * told to trust and Node was not, so these requests failed whatever the address said. They are taken when the peer
61
+ * presents exactly this server's certificate — its fingerprint, not a chain — and refused otherwise; nothing else the
62
+ * process reaches is verified any less.
63
+ */
64
+ var reachOwnServer = async ({ publicUrl, listener, cert }) => {
65
+ const outside = new URL(publicUrl);
66
+ const outsidePort = outside.port || (outside.protocol === "https:" ? "443" : "80");
67
+ const own = new X509Certificate(cert).fingerprint256;
68
+ const undici = await import("undici");
69
+ const { Agent, setGlobalDispatcher } = undici;
70
+ const connect = elsewhere(undici);
71
+ setGlobalDispatcher(new Agent({ connect: (options, callback) => {
72
+ if (options.hostname !== outside.hostname || (options.port || outsidePort) !== outsidePort) {
73
+ connect(options, callback);
74
+ return;
75
+ }
76
+ const socket = tls.connect({
77
+ host: listener.host,
78
+ port: listener.port,
79
+ ...net.isIP(outside.hostname) ? {} : { servername: outside.hostname },
80
+ ALPNProtocols: ["http/1.1"],
81
+ rejectUnauthorized: false
82
+ });
83
+ socket.once("secureConnect", () => {
84
+ if (socket.getPeerX509Certificate()?.fingerprint256 !== own) {
85
+ socket.destroy();
86
+ callback(/* @__PURE__ */ new Error(`${outside.host} answered with a certificate that is not this server's`), null);
87
+ return;
88
+ }
89
+ callback(null, socket);
90
+ });
91
+ socket.once("error", (error) => callback(error, null));
92
+ } }));
93
+ };
46
94
  //#endregion
47
- export { reachSpaceInside };
95
+ export { reachOwnServer, reachSpaceInside };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import { requestAuthority } from "../../core/requestParser.js";
1
2
  import { requestFlagOverrides } from "../../helpers/flagOverrides.js";
2
3
  import { authorizesDebugging } from "../../helpers/debugAuthorization.js";
3
4
  import { SPACE_TOKEN_PARAM } from "../../core/auth/credentials.js";
@@ -119,7 +120,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
119
120
  * so does the space itself (`settings.debugMode`) — an owner inspecting their own published site. The space is read
120
121
  * from what this server loaded, never from the request, so a visitor has no say in it.
121
122
  */
122
- const debugRendered = resolveDebugMode(debugAuthorized, readCookie(req.headers.cookie, debugCookieName(req.headers.host)));
123
+ const debugRendered = resolveDebugMode(debugAuthorized, readCookie(req.headers.cookie, debugCookieName(requestAuthority(req))));
123
124
  /**
124
125
  * The runs this render's server elements started, handed to a page that may debug them — and to no other.
125
126
  *
@@ -159,7 +160,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
159
160
  * travels as the SDK's `state`, the starting `runtime.state`, in the render and in the payload alike, or the client
160
161
  * would hydrate other markup. Only the keys the space declares, and only with `keepState` on.
161
162
  */
162
- const paintedState = schema?.settings.keepState ? paintedStateFor(req.headers.cookie, ssrPaintedCookieName(req.headers.host), paintedKeys(schema.settings)) : void 0;
163
+ const paintedState = schema?.settings.keepState ? paintedStateFor(req.headers.cookie, ssrPaintedCookieName(requestAuthority(req)), paintedKeys(schema.settings)) : void 0;
163
164
  /**
164
165
  * The two layers of the space's flags this server answers for. Its own (`config.flags`), asked per space when it
165
166
  * serves several; and the ones a tester forced from the dev tools, read from their cookie — only for a page allowed
@@ -12,6 +12,8 @@ export declare class PluginManager {
12
12
  private readonly devMode;
13
13
  /** Who is told what each built plugin was built from — a dev server's watcher (`watchPluginSources`). */
14
14
  private readonly sourceListeners;
15
+ /** The bundles being built again because they vanished from disk under this server — see `restore`. */
16
+ private restoring?;
15
17
  constructor(plugins: Record<string, PluginSource>, cacheDir?: string, ttlMs?: number, devMode?: boolean);
16
18
  /**
17
19
  * Tells `listener` the files every built plugin was built from — those built so far, at once, and each one built or
@@ -108,6 +110,13 @@ export declare class PluginManager {
108
110
  */
109
111
  prepareAll(): Promise<void>;
110
112
  prepare(name: string): Promise<PluginEntry | null>;
113
+ /**
114
+ * Bundles that vanished from disk under a running server are built again as one episode: said once when the first
115
+ * is noticed and once when the last is built — how many, how long, which failed — at the level it was noticed at.
116
+ * Said per plugin, it was a warning for each and a "ready" for each at a level a project's terminal does not show,
117
+ * so the rebuild never said it had finished.
118
+ */
119
+ private restore;
111
120
  private build;
112
121
  getEntries(names: string[]): Promise<PluginEntry[]>;
113
122
  getComponents(): Record<string, unknown>;
@@ -11,6 +11,8 @@ import path from "node:path";
11
11
  var META_FILE = "meta.json";
12
12
  var DEFAULT_TTL_MS = 6048e5;
13
13
  var DEFAULT_CACHE_DIR = "tmp/.sdk-plugins";
14
+ /** How long a rebuild of vanished bundles waits, once none is pending, for another of the same render to be noticed. */
15
+ var RESTORE_SETTLE_MS = 100;
14
16
  /** One hash over every input's path and content — a file gone counts as changed, like a file edited. */
15
17
  var digestOf = async (files) => {
16
18
  const hash = createHash("sha256");
@@ -49,6 +51,8 @@ var PluginManager = class {
49
51
  devMode = false;
50
52
  /** Who is told what each built plugin was built from — a dev server's watcher (`watchPluginSources`). */
51
53
  sourceListeners = /* @__PURE__ */ new Set();
54
+ /** The bundles being built again because they vanished from disk under this server — see `restore`. */
55
+ restoring;
52
56
  constructor(plugins, cacheDir, ttlMs, devMode = false) {
53
57
  assertPluginSources(plugins);
54
58
  this.plugins = plugins;
@@ -262,6 +266,7 @@ var PluginManager = class {
262
266
  if (isComponentSource(source) && !source.js) return this.toEntry(key, false, void 0, source.props);
263
267
  if (!isComponentSource(source) && source.action === "cdn") return this.toRemoteEntry(key, source);
264
268
  const cached = this.mem.get(key);
269
+ let missing = false;
265
270
  if (cached && !this.isExpired(cached.compiledAt)) {
266
271
  if (await this.fileExists(path.join(this.pluginDir(key), "index.js"))) {
267
272
  /**
@@ -278,8 +283,9 @@ var PluginManager = class {
278
283
  force: true
279
284
  });
280
285
  } else {
281
- serverLog.warn("SSR", `Plugin "${key}" cache invalidated: output file missing, rebuilding…`);
286
+ serverLog.info("SSR", `Plugin "${key}" output missing, rebuilding…`);
282
287
  this.mem.delete(key);
288
+ missing = true;
283
289
  }
284
290
  }
285
291
  const meta = await this.readMeta(key);
@@ -315,8 +321,44 @@ var PluginManager = class {
315
321
  }
316
322
  const promise = this.build(key, source).finally(() => this.inflight.delete(key));
317
323
  this.inflight.set(key, promise);
324
+ if (missing) this.restore(key, promise);
318
325
  return promise;
319
326
  }
327
+ /**
328
+ * Bundles that vanished from disk under a running server are built again as one episode: said once when the first
329
+ * is noticed and once when the last is built — how many, how long, which failed — at the level it was noticed at.
330
+ * Said per plugin, it was a warning for each and a "ready" for each at a level a project's terminal does not show,
331
+ * so the rebuild never said it had finished.
332
+ */
333
+ restore(key, build) {
334
+ if (!this.restoring) {
335
+ this.restoring = {
336
+ started: Date.now(),
337
+ pending: /* @__PURE__ */ new Set(),
338
+ rebuilt: 0,
339
+ failed: []
340
+ };
341
+ const where = path.relative(process.cwd(), this.outputDir) || this.outputDir;
342
+ serverLog.warn("SSR", `Plugin bundles missing from ${where}: building them again…`);
343
+ }
344
+ const episode = this.restoring;
345
+ clearTimeout(episode.closing);
346
+ episode.pending.add(key);
347
+ build.then((entry) => {
348
+ episode.pending.delete(key);
349
+ if (entry) episode.rebuilt += 1;
350
+ else episode.failed.push(key);
351
+ if (episode.pending.size > 0) return;
352
+ episode.closing = setTimeout(() => {
353
+ if (this.restoring !== episode || episode.pending.size > 0) return;
354
+ this.restoring = void 0;
355
+ const seconds = ((Date.now() - episode.started) / 1e3).toFixed(1);
356
+ const failed = episode.failed.length > 0 ? ` — ${String(episode.failed.length)} failed (${episode.failed.join(", ")}), said above` : "";
357
+ serverLog.warn("SSR", `Plugin bundles built again: ${String(episode.rebuilt)} in ${seconds}s${failed}`);
358
+ }, RESTORE_SETTLE_MS);
359
+ episode.closing.unref();
360
+ });
361
+ }
320
362
  async build(name, source) {
321
363
  const dir = this.pluginDir(name);
322
364
  await fs.mkdir(dir, { recursive: true });
package/dist/runtime.d.ts CHANGED
@@ -9,7 +9,8 @@ export type { SpaceRuntime, SpaceRuntimeContext, SpaceRuntimeEndpoint, SpaceRunt
9
9
  export { RUNTIME_DESCRIBE_PATH, startSpaceRuntime } from './modules/runtime/host';
10
10
  export type { SpaceRuntimeDescription, SpaceRuntimeHost, SpaceRuntimeHostOptions } from './modules/runtime/host';
11
11
  export { inspectRuntime, loadRuntime, loadRuntimeModule, MAX_RUNTIME_BUNDLE_BYTES, packRuntime, runtimeBundleId } from './modules/runtime/bundle';
12
- export { reachSpaceInside } from './modules/runtime/inside';
12
+ export { reachOwnServer, reachSpaceInside } from './modules/runtime/inside';
13
+ export type { OwnServer } from './modules/runtime/inside';
13
14
  export { createRuntimeRunner } from './modules/runtime/runner';
14
15
  export { createRuntimeProxyStage, runtimeEndpointsStage, serveRuntime } from './modules/runtime/stages';
15
16
  export type { RuntimeProxyConfig, RuntimeTarget, ServedRuntime } from './modules/runtime/stages';
package/dist/runtime.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { defineRuntime, endpointFor, endpointProblem } from "./modules/runtime/contract.js";
2
- import { reachSpaceInside } from "./modules/runtime/inside.js";
2
+ import { reachOwnServer, reachSpaceInside } from "./modules/runtime/inside.js";
3
3
  import { createRuntimeRunner } from "./modules/runtime/runner.js";
4
4
  import { RUNTIME_DESCRIBE_PATH, startSpaceRuntime } from "./modules/runtime/host.js";
5
5
  import { MAX_RUNTIME_BUNDLE_BYTES, inspectRuntime, loadRuntime, loadRuntimeModule, packRuntime, runtimeBundleId } from "./modules/runtime/bundle.js";
6
6
  import { createRuntimeProxyStage, runtimeEndpointsStage, serveRuntime } from "./modules/runtime/stages.js";
7
- export { MAX_RUNTIME_BUNDLE_BYTES, RUNTIME_DESCRIBE_PATH, createRuntimeProxyStage, createRuntimeRunner, defineRuntime, endpointFor, endpointProblem, inspectRuntime, loadRuntime, loadRuntimeModule, packRuntime, reachSpaceInside, runtimeBundleId, runtimeEndpointsStage, serveRuntime, startSpaceRuntime };
7
+ export { MAX_RUNTIME_BUNDLE_BYTES, RUNTIME_DESCRIBE_PATH, createRuntimeProxyStage, createRuntimeRunner, defineRuntime, endpointFor, endpointProblem, inspectRuntime, loadRuntime, loadRuntimeModule, packRuntime, reachOwnServer, reachSpaceInside, runtimeBundleId, runtimeEndpointsStage, serveRuntime, startSpaceRuntime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.38.13",
3
+ "version": "0.38.14",
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.13",
100
- "@plitzi/sdk-shared": "0.38.13",
99
+ "@plitzi/plitzi-sdk": "0.38.14",
100
+ "@plitzi/sdk-shared": "0.38.14",
101
101
  "ejs": "^6.0.1",
102
102
  "esbuild": "^0.28.2",
103
103
  "jsonwebtoken": "^9.0.3",
@@ -145,6 +145,16 @@ Every page is now its content and nothing else, and every page is guaranteed the
145
145
  by name — `"{{ not (apiContainer_feed.products|find('slug', navigation.routeParams.slug)) }}"`. The page renders as
146
146
  written: show its "not found" part with `visible`.
147
147
 
148
+ ## A screen that fills the window
149
+
150
+ A dashboard or a board — one screen whose panels scroll on their own — says so on its page: `documentScroll:
151
+ 'locked'` holds the document still, so a finger dragged on a tablet no longer pulls the whole page and bounces it
152
+ back. Give the shell the window's height (`height: '100dvh'`) and `overflow: 'auto'` to each part that scrolls; what
153
+ does not fit is cut off, with nothing a visitor can scroll to reach it. `'noBounce'` keeps a page scrolling without
154
+ the rubber band or pull-to-refresh. Either is written onto the document only while that page is on it, and only when
155
+ the document is the space's own — never the site a widget sits on, nor a space drawn inside another. Not an
156
+ `html, body` rule in `customCss`, which holds every page and the site the space is a widget on.
157
+
148
158
  ## Styles follow the same rule
149
159
 
150
160
  - A role used on several pages is ONE class (`panelCard`, `pageTitle`), declared in the module that owns the area and
@@ -64,19 +64,19 @@ const NebulaRun = ({ shipColor = 'amber', best = 0, className }: NebulaRunProps)
64
64
  ## Writing to the space
65
65
 
66
66
  A plugin that produces something the page shows — a score, a selection — writes it to `runtime.state`, where every
67
- binding reads it:
67
+ binding reads it — and where two plugins share it, each being a bundle of its own (a module both import is two):
68
68
 
69
69
  ```ts
70
70
  import { getStateManager } from '@plitzi/plitzi-sdk';
71
71
 
72
72
  const state = getStateManager();
73
73
  state.setStateByKey('arcadeBest', Math.max(score, Number(state.state.arcadeBest ?? 0)));
74
- const stop = state.subscribe(next => save(next)); // called after every change; call `stop()` to leave
74
+ const stop = state.subscribe(next => save(next)); // after every change; `stop()` to leave
75
75
  ```
76
76
 
77
77
  `state` is the current value, `setState` replaces it (or takes an updater), `setStateByKey` writes one key,
78
- `clearState` empties it, `subscribe` listens. Bindings on `state.*` re-render at once. With the space's `keepState`
79
- on ([kept state](kept-state.md)), what a plugin writes there — its layout, a choice — is back after a reload too.
78
+ `clearState` empties it, `subscribe` listens. Bindings on `state.*` re-render at once. With `keepState` on
79
+ ([kept state](kept-state.md)), what a plugin writes there is back after a reload.
80
80
 
81
81
  ## Laying out the space's elements
82
82