@farm.js/islx 0.0.0-stage → 0.0.1
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 +43 -0
- package/LICENSE +21 -0
- package/README.md +198 -2
- package/SPEC.md +220 -0
- package/dist/_chunks/actions-BCtIGvrP.d.mts +58 -0
- package/dist/_chunks/actions-C4dZ1-XD.mjs +615 -0
- package/dist/_chunks/actions-CA_K-In7.mjs +310 -0
- package/dist/_chunks/actions-Eutk6UzT.mjs +326 -0
- package/dist/_chunks/build-COIcs7wm.mjs +9 -0
- package/dist/_chunks/codec-BTDkzzPP.mjs +271 -0
- package/dist/_chunks/codec-CK8kZp4u.d.mts +64 -0
- package/dist/_chunks/handle-C0N9ZyNV.d.mts +35 -0
- package/dist/_chunks/hooks-3VZV8f_d.d.mts +30 -0
- package/dist/_chunks/hooks-De0gNpOn.d.mts +64 -0
- package/dist/_chunks/prehydration-B1GLTLdk.mjs +36 -0
- package/dist/_chunks/schedule-BFoGKRrU.mjs +216 -0
- package/dist/_chunks/schedule-Dw14dW0H.d.mts +17 -0
- package/dist/_chunks/tree-BEV73f_8.mjs +777 -0
- package/dist/_chunks/tree-fID6sz25.d.mts +102 -0
- package/dist/_chunks/value-store-Dm-7xJ6W.d.mts +19 -0
- package/dist/_chunks/wire-C3Rpbwl6.d.mts +98 -0
- package/dist/_chunks/wire-CXOodE06.mjs +121 -0
- package/dist/client.d.mts +58 -0
- package/dist/client.mjs +197 -0
- package/dist/compiler.d.mts +2 -0
- package/dist/compiler.mjs +1 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/react/client.d.mts +49 -0
- package/dist/react/client.mjs +403 -0
- package/dist/react/server.d.mts +51 -0
- package/dist/react/server.mjs +84 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +1 -0
- package/dist/server.d.mts +200 -0
- package/dist/server.mjs +378 -0
- package/dist/vite.d.mts +57 -0
- package/dist/vite.mjs +497 -0
- package/package.json +121 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
The first release: the islx wire format, server actions, React islands, and the Vite plugin.
|
|
6
|
+
|
|
7
|
+
### Wire format (`@farm.js/islx`)
|
|
8
|
+
|
|
9
|
+
- `f-` vocabulary: `<f-island>`, `f/props`, `f/value`, `<f-slot>`, `f/tree`, `f/chunk`, `<f-html>`,
|
|
10
|
+
`f/form-state`; headers `F-Values`, `F-Layouts`, `F-Render`, `F-Action`.
|
|
11
|
+
- One reserved JSON key, `$`, with short tags: `Date`, `Map`, `Set`, `BigInt`, `URL`, `RegExp`,
|
|
12
|
+
bytes, `undefined`, special numbers, `FormData` and file arguments; app data with a `$` key
|
|
13
|
+
travels wrapped. Decoding refuses unknown tags and keeps `__proto__` an ordinary key.
|
|
14
|
+
|
|
15
|
+
### Server (`@farm.js/islx/server`)
|
|
16
|
+
|
|
17
|
+
- Values sent once: large props keyed by a hash of their JSON, left out for browsers that hold
|
|
18
|
+
them.
|
|
19
|
+
- Server actions: `"use server"` modules and inline `"use server"` functions. Captured variables
|
|
20
|
+
and `.bind` arguments travel sealed (AES-GCM, bound to the action, key rotation).
|
|
21
|
+
- `handleAction(request)`: calls and forms without JavaScript, `<button formAction>`,
|
|
22
|
+
`useActionState` form state, origin checks, a body limit, stale-deploy answers, errors that
|
|
23
|
+
never reach the browser.
|
|
24
|
+
- Lifecycle hooks: `defineServerHooks({ action, actionError, error })`.
|
|
25
|
+
|
|
26
|
+
### Browser (`@farm.js/islx/client`)
|
|
27
|
+
|
|
28
|
+
- Action references: calls in order, refresh after each, redirects, stale-deploy reloads.
|
|
29
|
+
- Client navigation, view transitions, server-component forms without reloads.
|
|
30
|
+
- Lifecycle hooks and `f:` DOM events; the pre-hydration script that holds early interactions.
|
|
31
|
+
|
|
32
|
+
### React (`@farm.js/islx/react`, `/react/server`, `/react/client`)
|
|
33
|
+
|
|
34
|
+
- `createIsland`, `renderToStream`, the hydration runtime (`load`, `idle`, `visible`,
|
|
35
|
+
`interaction`), carry-over across refreshes, server HTML restored on failure.
|
|
36
|
+
- Server trees for layouts that hydrate as one root, payload-only navigations.
|
|
37
|
+
- For frameworks: `IslandsProvider`, `withinClientRoot`, `isolateIslands`, `createServerHtml`.
|
|
38
|
+
|
|
39
|
+
### Build (`@farm.js/islx/compiler`, `@farm.js/islx/vite`)
|
|
40
|
+
|
|
41
|
+
- `transformActions` and `transformClientModule`, with errors that say where and what to do.
|
|
42
|
+
- `islx()` for Vite 6+: discovery, one chunk per client module, preloads, dev reloads,
|
|
43
|
+
`directives`, `hooks`, `navigation`, `clientComponents`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Farming Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,199 @@
|
|
|
1
|
-
#
|
|
1
|
+
# islx
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A wire format for server-rendered islands. Pages are plain server HTML; the interactive parts
|
|
4
|
+
hydrate on their own, with typed props, values sent once, server actions, and navigations that
|
|
5
|
+
send only what changed.
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<f-island src="/src/cart.tsx" export="Cart" props="_R_1_" strategy="load">…server HTML…</f-island>
|
|
9
|
+
<script type="f/props" for="_R_1_">{"updated":{"$":"D","v":"2026-10-28T09:00:00.000Z"}}</script>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
islx started as [Farm.js](https://github.com/farming-labs/farm.js)'s islands mode, where it
|
|
13
|
+
replaces React Server Components' Flight payload. On a 300-page docs app it ships 26% less
|
|
14
|
+
JavaScript, and a client navigation sends 2.5 KB instead of 12 KB.
|
|
15
|
+
|
|
16
|
+
> Status: 0.0.1, the first release. React and Vite are supported; other renderers and framework
|
|
17
|
+
> integrations follow. The wire format is in [SPEC.md](./SPEC.md), changes in
|
|
18
|
+
> [CHANGELOG.md](./CHANGELOG.md).
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @farm.js/islx
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What is here
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { encode, decode } from "@farm.js/islx";
|
|
28
|
+
import { addressValues, selectValuesToSend, handleAction } from "@farm.js/islx/server";
|
|
29
|
+
import { getValueStore, createServerReference, setActionNavigator } from "@farm.js/islx/client";
|
|
30
|
+
import { transformActions } from "@farm.js/islx/compiler";
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **Typed values.** `Date`, `Map`, `Set`, `BigInt`, `URL`, `RegExp`, bytes, `undefined` and
|
|
34
|
+
special numbers survive the trip, in props and in action arguments and results.
|
|
35
|
+
- **Values sent once.** Large props are keyed by a hash of their JSON. Islands and pages that
|
|
36
|
+
share one share its key, and a navigation leaves out what the browser holds.
|
|
37
|
+
- **Server actions.** `"use server"` modules and inline `"use server"` functions, with:
|
|
38
|
+
- the variables an inline action reads from its component carried along, sealed (AES-GCM,
|
|
39
|
+
bound to the action) so the browser can neither read nor change them;
|
|
40
|
+
- forms that work before JavaScript loads, including `<button formAction>` and
|
|
41
|
+
`useActionState`;
|
|
42
|
+
- `.bind` on the server (sealed) and in the browser;
|
|
43
|
+
- calls run in order, then a page refresh; a redirect navigates; an action from an older
|
|
44
|
+
deploy reloads the page;
|
|
45
|
+
- same-origin checks, a body size limit, and errors that never reach the browser.
|
|
46
|
+
- **React islands.** `createIsland` renders a client component as server HTML plus its props;
|
|
47
|
+
`renderToStream` renders a page (leaving out values the browser holds); the hydration runtime
|
|
48
|
+
starts each island as its own root when its strategy says (`load`, `idle`, `visible`,
|
|
49
|
+
`interaction`, which holds the first click or submit and replays it), restores the server HTML
|
|
50
|
+
if an island fails, keeps island state when the page renders again, and hydrates
|
|
51
|
+
`useActionState` with the form post's result.
|
|
52
|
+
- **Compiler.** A pure function from source to source for each environment, with errors that
|
|
53
|
+
say where and what to do: an inline action in browser code, one that is not async, uses
|
|
54
|
+
`this`, or captures something that cannot travel.
|
|
55
|
+
|
|
56
|
+
### Server actions in a framework
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// The server: before rendering a page.
|
|
60
|
+
const response = await handleAction(request, {
|
|
61
|
+
allowedOrigins: ["app.example.com"],
|
|
62
|
+
mapError: (error) => (isRedirect(error) ? { redirect: error.location } : undefined),
|
|
63
|
+
renderFormState: (formState) => renderPage(request, { formState }),
|
|
64
|
+
});
|
|
65
|
+
if (response) return response;
|
|
66
|
+
|
|
67
|
+
// The browser: once.
|
|
68
|
+
setActionNavigator({ refresh: () => router.refresh(), navigate: (href) => router.push(href) });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Every server instance must share the key captured values are sealed with: set
|
|
72
|
+
`ISLX_ACTIONS_KEY` (make one with `generateActionKey()`), or call `configureActionKeys`. Keep a
|
|
73
|
+
rotated-out key in `ISLX_ACTIONS_PREVIOUS_KEYS` so open pages keep working.
|
|
74
|
+
|
|
75
|
+
### Known limits
|
|
76
|
+
|
|
77
|
+
- Every registered action is a public endpoint, as in React and Next.js: check inputs and rights
|
|
78
|
+
inside it.
|
|
79
|
+
- Arguments carry the typed values above, `FormData`, `File` and `Blob`.
|
|
80
|
+
- Actions are not removed from the server build when nothing imports them yet.
|
|
81
|
+
|
|
82
|
+
## On Vite
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// vite.config.ts
|
|
86
|
+
import react from "@vitejs/plugin-react";
|
|
87
|
+
import { islx } from "@farm.js/islx/vite";
|
|
88
|
+
|
|
89
|
+
export default defineConfig({
|
|
90
|
+
plugins: [react(), islx()],
|
|
91
|
+
environments: {
|
|
92
|
+
ssr: { build: { outDir: "dist/server", rollupOptions: { input: "src/entry-server.tsx" } } },
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// src/entry-server.tsx
|
|
99
|
+
import { renderToStream } from "@farm.js/islx/react/server";
|
|
100
|
+
import { handleAction } from "@farm.js/islx/server";
|
|
101
|
+
|
|
102
|
+
export async function handle(request: Request) {
|
|
103
|
+
const action = await handleAction(request, {
|
|
104
|
+
renderFormState: (formState) => page(request, formState),
|
|
105
|
+
});
|
|
106
|
+
return action ?? page(request);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function page(request: Request, formState?) {
|
|
110
|
+
const stream = await renderToStream(<App />, { request, formState });
|
|
111
|
+
return new Response(stream, { headers: { "content-type": "text/html", "cache-control": "private" } });
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
// src/components/chart.tsx
|
|
117
|
+
"use client";
|
|
118
|
+
export const hydrate = "visible"; // or { Chart: "visible", Legend: "idle" }; default "load"
|
|
119
|
+
export function Chart(props: { points: Map<string, number> }) { … }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- Components are server components unless their module says `"use client"`; those render as
|
|
123
|
+
islands, and the page needs no other setup: `renderToStream` adds the browser entry.
|
|
124
|
+
- `vite build` builds the server, then the browser with exactly the client components the server
|
|
125
|
+
rendered, each in its own chunk. `islx({ clientComponents: { include, exclude } })` adds
|
|
126
|
+
components the server build cannot see (chosen by name at runtime) or leaves some out.
|
|
127
|
+
- Forms in server components submit without a reload once JavaScript runs, and post normally
|
|
128
|
+
before.
|
|
129
|
+
- Same-site links move between pages without loading a new document: the runtime, its code and
|
|
130
|
+
the values the browser holds stay, and the server leaves those values out. Back, forward,
|
|
131
|
+
scroll and focus behave as page loads do. `islx({ navigation: false })` turns it off;
|
|
132
|
+
`rel="external"` opts one link out; `navigate(href)` from `@farm.js/islx/client` navigates from
|
|
133
|
+
code.
|
|
134
|
+
- `islx({ directives: { client: "use island", server: "use action" } })` renames the directives
|
|
135
|
+
your code uses, for apps where another tool already owns `"use client"` or `"use server"`.
|
|
136
|
+
Libraries keep `"use client"`: their components become islands when Vite bundles them for the
|
|
137
|
+
server (`ssr.noExternal`), and their `"use server"` never registers an action.
|
|
138
|
+
- Lifecycle hooks go in the plugin's options:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
islx({
|
|
142
|
+
navigation: { viewTransition: true },
|
|
143
|
+
hooks: {
|
|
144
|
+
client: {
|
|
145
|
+
navigate(event) { if (window.unsaved) event.preventDefault(); }, // stay on the page
|
|
146
|
+
navigated({ url, duration }) { window.plausible?.("pageview", { u: url.href }); },
|
|
147
|
+
hydrate({ export: name, duration }) { console.debug(name, "interactive in", duration); },
|
|
148
|
+
islandError({ src, error }) { console.error(src, error); },
|
|
149
|
+
actionEnd({ name, ok, duration }) {},
|
|
150
|
+
},
|
|
151
|
+
server: {
|
|
152
|
+
async action({ request, name, module }, run) { /* check, log, time */ return run(); },
|
|
153
|
+
actionError: (error) => (error instanceof Unauthorized ? { status: 401 } : undefined),
|
|
154
|
+
error(error, { name }) {},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Callbacks are copied into the browser or server build as source, so they may use only their
|
|
161
|
+
arguments and globals; the build names anything else they use. Hooks that import app code (an
|
|
162
|
+
auth helper, an error reporter) go in a module: `hooks: { server: "./src/islx.server.ts" }`,
|
|
163
|
+
exporting `defineServerHooks({ … })` (or `defineClientHooks`). Every client hook also fires as a
|
|
164
|
+
DOM event on `document`: `f:navigate`, `f:navigated`, `f:navigate-error`, `f:hydrate`,
|
|
165
|
+
`f:island-error`, `f:action`, `f:action-end`.
|
|
166
|
+
- Each build makes its own key for sealed action values; set `ISLX_ACTIONS_KEY` to keep forms
|
|
167
|
+
working across deploys.
|
|
168
|
+
|
|
169
|
+
`examples/react` is a runnable app; `pnpm test:e2e` runs it in Chrome, in development and from
|
|
170
|
+
its production build.
|
|
171
|
+
|
|
172
|
+
## In a framework
|
|
173
|
+
|
|
174
|
+
A framework that renders pages itself (Farm does) uses the pieces directly instead of
|
|
175
|
+
`renderToStream` and the Vite plugin's entry:
|
|
176
|
+
|
|
177
|
+
| Need | API |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| Compile client and action modules | `transformClientModule({ …, reference, strategy })`, `transformActions` from `@farm.js/islx/compiler` |
|
|
180
|
+
| Render islands in its own render | wrap the page in `<IslandsProvider held={parseHeldValues(request)}>` |
|
|
181
|
+
| A layout that hydrates as one root | `withinClientRoot(layout)`; its server-HTML page: `isolateIslands(page)` |
|
|
182
|
+
| An async page under such a layout | `renderServerTree(page, { held, payloadOnly })` on the server; `createServerTreeElement(source, { load, ServerHtml })` in the browser |
|
|
183
|
+
| Hydrate | `createHydrationRuntime({ load, schedule?, wrap?, report? })`, `createServerHtml(runtime)` |
|
|
184
|
+
| Hold early clicks | inline `PREHYDRATION_SCRIPT` in the page |
|
|
185
|
+
| Actions | `handleAction(request, { allowedOrigins, bodySizeLimit, run, mapError, renderFormState })`; `setActionNavigator({ refresh, navigate })` |
|
|
186
|
+
| Navigation with its router | send `F-Values` (`valueHeaders()`), `F-Layouts`, `F-Render`; `refreshDocument` / `swapDocument` or its own swap |
|
|
187
|
+
|
|
188
|
+
## Planned
|
|
189
|
+
|
|
190
|
+
| Entry | For |
|
|
191
|
+
| --- | --- |
|
|
192
|
+
| `@farm.js/islx/react` | server trees (a page rebuilt inside a hydrated layout), partial navigations |
|
|
193
|
+
| `@farm.js/islx/webpack` | webpack, Rspack, and Next.js (Turbopack uses the loader) |
|
|
194
|
+
| `@farm.js/islx/next`, `@farm.js/islx/astro`, `@farm.js/islx/nuxt` | thin framework integrations |
|
|
195
|
+
| `@farm.js/islx/testing` | the conformance suite every integration must pass |
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# islx wire format
|
|
2
|
+
|
|
3
|
+
Version 1. This document is the contract between a islx server and a islx browser runtime.
|
|
4
|
+
Anything that writes and reads it, in any language or framework, is a islx implementation.
|
|
5
|
+
|
|
6
|
+
## Principles
|
|
7
|
+
|
|
8
|
+
- **HTML first.** A page is ordinary server HTML. Interactive parts are marked where they sit;
|
|
9
|
+
nothing else is reserved.
|
|
10
|
+
- **One prefix.** Every element, script type and header islx owns starts with `f`. Custom
|
|
11
|
+
elements need a hyphen (`f-island`), script types use a slash (`f/props`), headers start `F-`.
|
|
12
|
+
- **One reserved JSON key.** Inside JSON, `$` holds a short tag. App data that has its own `$`
|
|
13
|
+
key is wrapped, so no key is off limits to apps.
|
|
14
|
+
- **Untrusted by default.** Everything the browser sends back (headers, action arguments, sealed
|
|
15
|
+
values) is validated. References are never accepted from the browser unless the server checks
|
|
16
|
+
them.
|
|
17
|
+
|
|
18
|
+
## HTML
|
|
19
|
+
|
|
20
|
+
```html
|
|
21
|
+
<f-island src="/src/toc.tsx" export="Toc" props="_R_b_" strategy="load">
|
|
22
|
+
…the component's server HTML…
|
|
23
|
+
<f-slot name="_R_5_s0">…server output passed as children…</f-slot>
|
|
24
|
+
</f-island>
|
|
25
|
+
<script type="f/value" name="04jiw29097x8ki">[…a value sent once…]</script>
|
|
26
|
+
<script type="f/props" for="_R_b_">{"headings":[…],"tree":{"$":"r","v":"04jiw29097x8ki"}}</script>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Name | Meaning |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `<f-island>` | A client component's server HTML. `src` names the module the browser imports (its path from the app root; the runtime maps it to a URL), `export` the component's export name, `props` the id of its props script, `strategy` when it hydrates: `load`, `idle`, `visible` or `interaction`. A hydrated island gets a `hydrated` attribute. |
|
|
32
|
+
| `<script type="f/props" for>` | An island's props, as JSON. `for` names the island's `props` id. |
|
|
33
|
+
| `<script type="f/value" name>` | A value sent once. `name` is its key. Written before the first island that uses it. |
|
|
34
|
+
| `<f-slot name>` | Server output passed to an island as children. It stays server HTML; the island renders it in place. |
|
|
35
|
+
| `<script type="f/tree" [partial]>` | A page's server tree, so the browser can rebuild the page inside an existing root without its HTML. `partial` marks a navigation that sent only the tree. |
|
|
36
|
+
| `<script type="f/chunk" name>` | A part of a server tree that streamed in after the tree (a resolved Suspense boundary). |
|
|
37
|
+
| `<f-html name>` | Server HTML inside a server tree. |
|
|
38
|
+
|
|
39
|
+
A value script's and props script's JSON escapes `<`, U+2028 and U+2029.
|
|
40
|
+
|
|
41
|
+
## JSON tags
|
|
42
|
+
|
|
43
|
+
A tagged object is `{"$":tag}` or `{"$":tag,"v":data}`; an action may also carry `"b"`. An object
|
|
44
|
+
with any other keys is data.
|
|
45
|
+
|
|
46
|
+
| Tag | Stands for | Data |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `r` | a value sent once | its key |
|
|
49
|
+
| `s` | a slot | its name |
|
|
50
|
+
| `a` | a server action | its id; `b`, when present, holds its sealed captured values |
|
|
51
|
+
| `o` | app data with a `$` key | the object |
|
|
52
|
+
| `u` | `undefined` | |
|
|
53
|
+
| `n` | `NaN`, `Infinity`, `-Infinity`, `-0` | the name |
|
|
54
|
+
| `B` | `BigInt` | decimal digits |
|
|
55
|
+
| `D` | `Date` | ISO string, `null` for an invalid date |
|
|
56
|
+
| `M` | `Map` | `[[key, value], …]`, both tagged |
|
|
57
|
+
| `S` | `Set` | `[value, …]`, tagged |
|
|
58
|
+
| `U` | `URL` | `href` |
|
|
59
|
+
| `R` | `RegExp` | `[source, flags]` |
|
|
60
|
+
| `Y` | bytes | `[type, base64]`; type is `ArrayBuffer`, `DataView` or a typed array name |
|
|
61
|
+
| `p` | a Suspense boundary inside a server tree | |
|
|
62
|
+
| `F` | a `FormData` action argument | its index among the call's attachments |
|
|
63
|
+
| `K` | a `Blob` or `File` action argument | its index among the call's attachments |
|
|
64
|
+
|
|
65
|
+
The browser resolves `r` first (from its value store), then decodes the rest. Inside `o`, only
|
|
66
|
+
the object's properties are read; the object itself is never a tag. Unknown tags are errors.
|
|
67
|
+
|
|
68
|
+
Decoders build plain objects with own properties, so a `__proto__` key stays an ordinary key.
|
|
69
|
+
|
|
70
|
+
## Values sent once
|
|
71
|
+
|
|
72
|
+
A value whose JSON reaches 512 characters travels in its own `f/value` script, keyed by a 64-bit
|
|
73
|
+
hash of its JSON (14 base-36 characters), and props refer to it as `{"$":"r","v":key}`. Inner
|
|
74
|
+
values are addressed first, so a value that changed in one place shares its unchanged parts. A
|
|
75
|
+
thin wrapper around one addressed value is not addressed.
|
|
76
|
+
|
|
77
|
+
The browser keeps every value it reads, up to 1024, and names the most recently used 128 in each
|
|
78
|
+
navigation:
|
|
79
|
+
|
|
80
|
+
```http
|
|
81
|
+
GET /docs/p1
|
|
82
|
+
F-Values: 04jiw29097x8ki,1kq0d3f7xg2m9a
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The server leaves those values out. A response that left values out belongs to one browser: it
|
|
86
|
+
must never enter a shared cache (CDN, ISR entry, PPR shell), and a cacheable response sends
|
|
87
|
+
`Vary: F-Values`.
|
|
88
|
+
|
|
89
|
+
## Navigations
|
|
90
|
+
|
|
91
|
+
| Header | Meaning |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `F-Values` | value keys the browser holds |
|
|
94
|
+
| `F-Layouts` | the layouts on screen, as a JSON array of layout keys (pattern with params filled in) |
|
|
95
|
+
| `F-Render: tree` | the browser can rebuild the page from its server tree; HTML may be left out |
|
|
96
|
+
| `F-Render: document` | a navigation: the browser puts the whole page in place with DOM methods, so the server sends it in one piece (every Suspense boundary resolved, no streaming scripts) |
|
|
97
|
+
|
|
98
|
+
Without a framework router, the browser runtime navigates itself: a same-site link click fetches
|
|
99
|
+
the page with `F-Render: document` and `F-Values`, swaps the body in, starts the next page's
|
|
100
|
+
islands, and handles history, scroll and focus. Anything unusual loads the page normally (another
|
|
101
|
+
site, `target`, `download`, `rel="external"`, modifier keys, a non-HTML answer, or a page whose
|
|
102
|
+
browser entry differs, as after a deploy).
|
|
103
|
+
|
|
104
|
+
A server that leaves layouts out renders each kept layout as an empty boundary, and the browser
|
|
105
|
+
keeps its own. If the response does not fit what is on screen, the browser loads the full
|
|
106
|
+
document. All three headers make a response private, and the server sends `Vary` for each one
|
|
107
|
+
it read.
|
|
108
|
+
|
|
109
|
+
## Server actions
|
|
110
|
+
|
|
111
|
+
An action id names one server function: a hash of its module's path from the app root and its
|
|
112
|
+
export name (`inline:N` for the Nth inline action in the module). Ids are opaque to the browser.
|
|
113
|
+
Every registered action is a public endpoint: anyone can call it with any arguments, so an
|
|
114
|
+
action checks its inputs and the caller's rights like any other request handler.
|
|
115
|
+
|
|
116
|
+
An action's **bound values** are the variables an inline action captured, then arguments added
|
|
117
|
+
with `.bind` on the server. They travel sealed (below) and come before the call's own arguments.
|
|
118
|
+
|
|
119
|
+
**Called from JavaScript:**
|
|
120
|
+
|
|
121
|
+
```http
|
|
122
|
+
POST /current/page
|
|
123
|
+
F-Action: nSaHH8FImEr_M6
|
|
124
|
+
Content-Type: text/plain;charset=UTF-8
|
|
125
|
+
|
|
126
|
+
{"a":[…arguments, tagged…],"b":"…sealed…"}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A single `FormData` argument is sent as `multipart/form-data` instead, with the sealed values in
|
|
130
|
+
the `$F_BOUND` field. Arguments that hold `FormData` or files anywhere (as `useActionState`'s
|
|
131
|
+
`(previousState, formData)`) are sent as multipart too: the JSON goes in the `$F_CALL` field,
|
|
132
|
+
attachment `n` is `{"$":"F","v":n}` or `{"$":"K","v":n}` in it, a file travels as field `$F_n`
|
|
133
|
+
and a `FormData`'s fields as `$F_n_<name>`. The response is JSON: `{"result":…tagged…}` or `{"redirect":"/path"}`.
|
|
134
|
+
Arguments may contain actions (`a`, with `b` when they carry sealed values) the server
|
|
135
|
+
registered; never `r` or `s`. The browser runs calls one at a time, in order, and refreshes the
|
|
136
|
+
page after each one (a redirect navigates instead).
|
|
137
|
+
|
|
138
|
+
**Posted by a form without JavaScript:** React writes the fields while it renders the form, for
|
|
139
|
+
`<form action>` and `<button formAction>`:
|
|
140
|
+
|
|
141
|
+
```html
|
|
142
|
+
<form action="" method="POST" enctype="multipart/form-data">
|
|
143
|
+
<input type="hidden" name="$F_REF_R_1_">
|
|
144
|
+
<input type="hidden" name="$F_R_1_:A" value="…action id…">
|
|
145
|
+
<input type="hidden" name="$F_R_1_:B" value="…sealed…">
|
|
146
|
+
<button name="$F_REF_R_2_">Delete</button>
|
|
147
|
+
<input type="hidden" name="$F_R_2_:A" value="…another action…">
|
|
148
|
+
…
|
|
149
|
+
</form>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The last `$F_REF_` field a submission carries names the action, so a clicked button wins over
|
|
153
|
+
its form. Every `$F_` field and `$ACTION_KEY` is removed before the action sees the form. The
|
|
154
|
+
server answers `303 See Other` back to the page, or to the action's redirect. A form posted from
|
|
155
|
+
a `useActionState` hook carries React's `$ACTION_KEY`; a server that can render the page renders
|
|
156
|
+
it with React's form state `[result, $ACTION_KEY, id, bound count - 1]` instead of redirecting,
|
|
157
|
+
and hydrates with the same.
|
|
158
|
+
|
|
159
|
+
**Checks the server makes**, before running anything:
|
|
160
|
+
|
|
161
|
+
- the method is `POST`, and the request names a same-origin source (`Origin`, then `Referer`,
|
|
162
|
+
then `Sec-Fetch-Site: same-origin`), or one from an origin the app allows;
|
|
163
|
+
- the body is within the size limit (`413`); a form over the limit that does not post an action
|
|
164
|
+
is left to the app;
|
|
165
|
+
- the id is registered; an unknown id is `404` with `F-Action-Stale: 1` for calls, and a `303`
|
|
166
|
+
back to the page for forms, so the browser loads the page this deploy renders;
|
|
167
|
+
- arguments decode; malformed or unknown tags are `400`;
|
|
168
|
+
- sealed values open for this id: values sealed with a key the server does not have are stale
|
|
169
|
+
(`409`, `F-Action-Stale: 1`), anything else that does not open is `400`, and so is an inline
|
|
170
|
+
action that captured values posted without them.
|
|
171
|
+
|
|
172
|
+
A failing action answers `500` with no details; the error is reported on the server.
|
|
173
|
+
|
|
174
|
+
**Forms in server components** belong to no island. Once the browser runtime runs, a submit of a
|
|
175
|
+
form that posts an action is sent as a call (same fields, same checks) and the page refreshes,
|
|
176
|
+
keeping island state; without JavaScript it posts as above.
|
|
177
|
+
|
|
178
|
+
**Form state after a post.** A page rendered with React's form state carries it for hydration:
|
|
179
|
+
`<script type="f/form-state">…tagged JSON…</script>`. Every island root hydrates with it.
|
|
180
|
+
|
|
181
|
+
## Sealed captured values
|
|
182
|
+
|
|
183
|
+
An action written inside a component may read variables of the render around it. Those values
|
|
184
|
+
travel with the action, sealed, so the browser can neither read nor change them:
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
sealed = base64url( version ‖ key id ‖ IV ‖ AES-256-GCM(ciphertext ‖ tag) )
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- `version` is `1`; `key id` is the first byte of SHA-256 of the key; the IV is 12 random bytes;
|
|
191
|
+
- the additional data is `islx:action:<id>`, so values sealed for one action never open for
|
|
192
|
+
another;
|
|
193
|
+
- the plaintext is the captured values as a tagged JSON array.
|
|
194
|
+
|
|
195
|
+
A value sealed with a key the server does not have is **stale** (the page reloads); anything that
|
|
196
|
+
does not open is **invalid** (`400`). Servers may hold previous keys, so pages rendered before a
|
|
197
|
+
key rotation keep working. Every server instance must share the keys.
|
|
198
|
+
|
|
199
|
+
## Integration model
|
|
200
|
+
|
|
201
|
+
islx is layered so each framework needs only a thin integration:
|
|
202
|
+
|
|
203
|
+
| Layer | Package entry | Depends on |
|
|
204
|
+
| --- | --- | --- |
|
|
205
|
+
| Wire format and codec | `@farm.js/islx` | nothing (Web APIs) |
|
|
206
|
+
| Server: values, sealing, action handling | `@farm.js/islx/server` | `@farm.js/islx` |
|
|
207
|
+
| Browser: value store, hydration, navigation | `@farm.js/islx/client` | `@farm.js/islx` |
|
|
208
|
+
| Renderer adapters | `@farm.js/islx/react` (then Preact, Vue, Svelte, Solid) | the renderer |
|
|
209
|
+
| Compiler: `"use client"`, `"use server"`, inline actions | `@farm.js/islx/compiler` | a parser |
|
|
210
|
+
| Bundler plugins | `@farm.js/islx/vite`, `@farm.js/islx/webpack`, `@farm.js/islx/rollup`, `@farm.js/islx/esbuild` | the compiler |
|
|
211
|
+
| Frameworks | `@farm.js/islx/next`, `@farm.js/islx/astro`, `@farm.js/islx/nuxt`, … | the above |
|
|
212
|
+
|
|
213
|
+
- **Request handling** is one function on Web `Request` and `Response`, so any server mounts it:
|
|
214
|
+
a Next.js route handler or middleware, a Nitro event handler, Astro middleware, Hono, Express
|
|
215
|
+
through an adapter.
|
|
216
|
+
- **The compiler** is a pure function from source and environment (`server` or `client`) to
|
|
217
|
+
source, with no bundler in it. Each bundler plugin is a few lines around it, and Turbopack uses
|
|
218
|
+
the webpack loader.
|
|
219
|
+
- **Conformance.** `@farm.js/islx/testing` will ship the contract suite: an implementation passes when its
|
|
220
|
+
fixture app passes it, in development and production.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import MagicString from "magic-string";
|
|
2
|
+
|
|
3
|
+
// src/compiler/actions.d.ts
|
|
4
|
+
/** An ESTree node with source offsets, as Rollup, Rolldown, oxc and acorn produce. */
|
|
5
|
+
interface AstNode {
|
|
6
|
+
type: string;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
[key: string]: any;
|
|
10
|
+
}
|
|
11
|
+
interface TransformActionsOptions {
|
|
12
|
+
/** The module's source. */
|
|
13
|
+
code: string;
|
|
14
|
+
/** The module's file path. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** The app root: ids are made from paths relative to it, so they match on every machine. */
|
|
17
|
+
root: string;
|
|
18
|
+
/** Whether the output runs on the server or in the browser. */
|
|
19
|
+
environment: "server" | "client";
|
|
20
|
+
/** Parses JavaScript (or the language the plugin runs after) to an ESTree program. */
|
|
21
|
+
parse(code: string): AstNode;
|
|
22
|
+
/** Mixed into every id, so ids of one build are not guessable from another's. */
|
|
23
|
+
salt?: string;
|
|
24
|
+
/** The directive that marks actions, at the top of a module or a function. Default `"use server"`. */
|
|
25
|
+
directive?: string;
|
|
26
|
+
/** Where the runtime is imported from. */
|
|
27
|
+
runtime?: {
|
|
28
|
+
server?: string;
|
|
29
|
+
client?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
interface TransformActionsResult {
|
|
33
|
+
code: string;
|
|
34
|
+
map: ReturnType<MagicString["generateMap"]>;
|
|
35
|
+
}
|
|
36
|
+
/** A mistake in the source, with where it is. Bundlers show `loc` with a code frame. */
|
|
37
|
+
declare class ActionCompileError extends Error {
|
|
38
|
+
readonly loc: {
|
|
39
|
+
file: string;
|
|
40
|
+
line: number;
|
|
41
|
+
column: number;
|
|
42
|
+
};
|
|
43
|
+
readonly id: string;
|
|
44
|
+
constructor(message: string, file: string, code: string, offset: number);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether the module's directive prologue (the strings before any other statement, after
|
|
48
|
+
* comments) holds `directive`. A linear scan, for a cheap check before parsing.
|
|
49
|
+
*/
|
|
50
|
+
declare function hasModuleDirective(code: string, directive: string): boolean;
|
|
51
|
+
/** The id an action has on both sides: a hash of its module path from the root and its name. */
|
|
52
|
+
declare function getActionId(root: string, file: string, name: string, salt?: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Compiles a module's server actions, or returns null when it has none. Throws an
|
|
55
|
+
* `ActionCompileError` for a mistake, such as an inline action in browser code.
|
|
56
|
+
*/
|
|
57
|
+
declare function transformActions(options: TransformActionsOptions): TransformActionsResult | null;
|
|
58
|
+
export { getActionId as a, TransformActionsResult as i, AstNode as n, hasModuleDirective as o, TransformActionsOptions as r, transformActions as s, ActionCompileError as t };
|