@plitzi/sdk-server 0.38.12 → 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.
- package/CHANGELOG.md +158 -0
- package/README.md +17 -5
- package/dist/core/auth/session.d.ts +10 -2
- package/dist/core/auth/session.js +31 -6
- package/dist/core/http/stages/sessionRenewal.js +2 -1
- package/dist/core/http/stages/signInRoute.js +1 -1
- package/dist/core/http/stages/static.js +1 -1
- package/dist/core/mimeTypes.d.ts +10 -1
- package/dist/core/mimeTypes.js +2 -1
- package/dist/core/oauth/authorize.js +2 -1
- package/dist/core/requestParser.d.ts +6 -0
- package/dist/core/requestParser.js +8 -2
- package/dist/core/requestParser.test.d.ts +1 -0
- package/dist/core/services/action.js +1 -1
- package/dist/core/services/realtime.js +1 -1
- package/dist/core/staticFiles.d.ts +2 -1
- package/dist/core/staticFiles.js +5 -5
- package/dist/helpers/cache/keys.d.ts +1 -0
- package/dist/helpers/cache/keys.js +4 -3
- package/dist/helpers/flagOverrides.d.ts +2 -0
- package/dist/helpers/flagOverrides.js +2 -1
- package/dist/modules/project/serveProject.d.ts +3 -2
- package/dist/modules/project/serveProject.js +81 -14
- package/dist/modules/realtime/socket.js +1 -1
- package/dist/modules/rsc/handler.js +2 -1
- package/dist/modules/runtime/elsewhere.test.d.ts +1 -0
- package/dist/modules/runtime/inside.d.ts +21 -0
- package/dist/modules/runtime/inside.js +51 -3
- package/dist/modules/runtime/ownServer.test.d.ts +1 -0
- package/dist/modules/ssr/prepareRender.js +3 -2
- package/dist/plugins/manager.d.ts +9 -0
- package/dist/plugins/manager.js +43 -1
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +2 -2
- package/package.json +3 -3
- package/skills/plitzi-authoring/reference/layouts.md +10 -0
- package/skills/plitzi-authoring/reference/plugins.md +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,163 @@
|
|
|
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
|
+
|
|
152
|
+
## 0.38.13
|
|
153
|
+
|
|
154
|
+
### Patch Changes
|
|
155
|
+
|
|
156
|
+
- v0.38.13
|
|
157
|
+
- Updated dependencies
|
|
158
|
+
- @plitzi/plitzi-sdk@0.38.13
|
|
159
|
+
- @plitzi/sdk-shared@0.38.13
|
|
160
|
+
|
|
3
161
|
## 0.38.12
|
|
4
162
|
|
|
5
163
|
### 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
35
|
-
* opposite, because Secure + SameSite=None over plain http is
|
|
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
|
|
32
|
-
* opposite, because Secure + SameSite=None over plain http is
|
|
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
|
|
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);
|
package/dist/core/mimeTypes.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/mimeTypes.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/dist/core/staticFiles.js
CHANGED
|
@@ -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 };
|
|
@@ -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
|
|
58
|
-
debugHidden: readCookie(req.headers.cookie, debugCookieName(req
|
|
59
|
-
forcedFlags: readCookie(req.headers.cookie, flagsCookieName(req
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
81
|
+
var addressesFor = (host) => {
|
|
73
82
|
if (LOOPBACK.has(host)) return [];
|
|
74
|
-
return (host
|
|
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
|
-
|
|
122
|
-
const
|
|
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 ${
|
|
195
|
-
|
|
196
|
-
if (
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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>;
|
package/dist/plugins/manager.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
100
|
-
"@plitzi/sdk-shared": "0.38.
|
|
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)); //
|
|
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
|
|
79
|
-
|
|
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
|
|