@farm.js/islx 0.0.0-stage → 0.0.2
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 +55 -0
- package/LICENSE +21 -0
- package/README.md +198 -2
- package/SPEC.md +220 -0
- package/dist/_chunks/actions-2sTj0A2T.mjs +306 -0
- package/dist/_chunks/actions-B8dJ8f7L.mjs +615 -0
- package/dist/_chunks/actions-BCtIGvrP.d.mts +58 -0
- package/dist/_chunks/actions-Eutk6UzT.mjs +326 -0
- package/dist/_chunks/build-COIcs7wm.mjs +9 -0
- package/dist/_chunks/codec-CK8kZp4u.d.mts +64 -0
- package/dist/_chunks/codec-DqfVYKsx.mjs +279 -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-BDB5VEao.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 +456 -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
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { M as formFieldName, O as VALUES_HEADER, d as FORM_REF_PREFIX, k as VALUE_KEY_PATTERN } from "./wire-CXOodE06.mjs";
|
|
2
|
+
import { a as encode, c as needsObjectTag, o as encodeLeaf, r as decode, t as EncodeError } from "./codec-DqfVYKsx.mjs";
|
|
3
|
+
import { t as getBuildDefaults } from "./build-COIcs7wm.mjs";
|
|
4
|
+
// src/server/values.ts
|
|
5
|
+
/**
|
|
6
|
+
* Values sent once: which parts of an island's props travel as values of their own, keyed by a
|
|
7
|
+
* hash of their JSON, and which of those a response leaves out because the browser already holds
|
|
8
|
+
* them. The browser side is `@farm.js/islx/client` (`getValueStore`).
|
|
9
|
+
*/
|
|
10
|
+
/** Shorter values stay inline: a reference and its script cost about 60 bytes. */
|
|
11
|
+
const VALUE_MIN_LENGTH = 512;
|
|
12
|
+
/** A 64-bit hash of a value's JSON, as 14 base-36 characters. Not cryptographic. */
|
|
13
|
+
function hashValue(json) {
|
|
14
|
+
let h1 = 3735928559 ^ json.length;
|
|
15
|
+
let h2 = 1103547991 ^ json.length;
|
|
16
|
+
for (let index = 0; index < json.length; index++) {
|
|
17
|
+
const code = json.charCodeAt(index);
|
|
18
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
19
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
20
|
+
}
|
|
21
|
+
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
|
|
22
|
+
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
|
|
23
|
+
return (h2 >>> 0).toString(36).padStart(7, "0") + (h1 >>> 0).toString(36).padStart(7, "0");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Serializes props with every value whose JSON reaches `minLength` replaced by a reference to it.
|
|
27
|
+
* Types JSON cannot hold are tagged first (the codec), so a `Map` or a `Date` is addressed like
|
|
28
|
+
* any other value. Inner values are addressed first, so a large value that differs between pages
|
|
29
|
+
* in one place still shares its unchanged parts.
|
|
30
|
+
*/
|
|
31
|
+
function addressValues(props, { minLength = 512, encodeSpecial, encoded = false } = {}) {
|
|
32
|
+
const values = /* @__PURE__ */ new Map();
|
|
33
|
+
const reference = (json, children, parent) => {
|
|
34
|
+
const key = hashValue(json);
|
|
35
|
+
if (!values.has(key)) values.set(key, {
|
|
36
|
+
key,
|
|
37
|
+
json,
|
|
38
|
+
children
|
|
39
|
+
});
|
|
40
|
+
parent.push(key);
|
|
41
|
+
return `{"$":"r","v":"${key}"}`;
|
|
42
|
+
};
|
|
43
|
+
const seen = /* @__PURE__ */ new Set();
|
|
44
|
+
const encode = (value, parent) => {
|
|
45
|
+
const special = encoded ? void 0 : encodeSpecial?.(value);
|
|
46
|
+
if (special !== void 0) {
|
|
47
|
+
const json = JSON.stringify(special);
|
|
48
|
+
return [json, json.length];
|
|
49
|
+
}
|
|
50
|
+
const leaf = encoded ? void 0 : encodeLeaf(value);
|
|
51
|
+
if (leaf) {
|
|
52
|
+
const json = JSON.stringify(leaf);
|
|
53
|
+
return [json, json.length];
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === "function") throw new TypeError("functions are not serializable");
|
|
56
|
+
if (typeof value === "symbol") throw new TypeError("symbol values are not serializable");
|
|
57
|
+
if (value === null || typeof value !== "object") {
|
|
58
|
+
const json = JSON.stringify(value);
|
|
59
|
+
return [typeof value === "string" && json.length >= minLength ? reference(json, [], parent) : json, json.length];
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(value)) throw new TypeError("circular values are not serializable");
|
|
62
|
+
seen.add(value);
|
|
63
|
+
try {
|
|
64
|
+
if (!encoded && (value instanceof Map || value instanceof Set)) {
|
|
65
|
+
const entries = value instanceof Map ? Array.from(value, ([key, entry]) => [key, entry]) : [...value];
|
|
66
|
+
const [json, length] = encode(entries, parent);
|
|
67
|
+
const prefix = `{"$":"${value instanceof Map ? "M" : "S"}","v":`;
|
|
68
|
+
return [`${prefix}${json}}`, length + prefix.length + 1];
|
|
69
|
+
}
|
|
70
|
+
const prototype = Object.getPrototypeOf(value);
|
|
71
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw new TypeError("class instances are not serializable");
|
|
72
|
+
const own = [];
|
|
73
|
+
let length = 2;
|
|
74
|
+
const parts = [];
|
|
75
|
+
if (Array.isArray(value)) for (const entry of value) {
|
|
76
|
+
const [json, entryLength] = encode(entry, own);
|
|
77
|
+
parts.push(json);
|
|
78
|
+
length += entryLength + 1;
|
|
79
|
+
}
|
|
80
|
+
else for (const [key, entry] of Object.entries(value)) {
|
|
81
|
+
const [json, entryLength] = encode(entry, own);
|
|
82
|
+
const name = JSON.stringify(key);
|
|
83
|
+
parts.push(`${name}:${json}`);
|
|
84
|
+
length += name.length + entryLength + 2;
|
|
85
|
+
}
|
|
86
|
+
let json = Array.isArray(value) ? `[${parts.join(",")}]` : `{${parts.join(",")}}`;
|
|
87
|
+
if (!encoded && !Array.isArray(value) && needsObjectTag(value)) {
|
|
88
|
+
json = `{"$":"o","v":${json}}`;
|
|
89
|
+
length += 14;
|
|
90
|
+
}
|
|
91
|
+
const wrapper = own.length === 1 && json.length < minLength / 4;
|
|
92
|
+
if (length >= minLength && !wrapper) return [reference(json, own, parent), length];
|
|
93
|
+
parent.push(...own);
|
|
94
|
+
return [json, length];
|
|
95
|
+
} finally {
|
|
96
|
+
seen.delete(value);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const roots = [];
|
|
100
|
+
return {
|
|
101
|
+
json: `{${Object.entries(props).map(([key, entry]) => `${JSON.stringify(key)}:${encode(entry, roots)[0]}`).join(",")}}`,
|
|
102
|
+
values,
|
|
103
|
+
roots
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The values a response sends for `addressed`, parents first. A value the browser holds is left
|
|
108
|
+
* out with everything inside it: the browser kept those when it read the value.
|
|
109
|
+
*/
|
|
110
|
+
function selectValuesToSend(addressed, held) {
|
|
111
|
+
const send = [];
|
|
112
|
+
const seen = /* @__PURE__ */ new Set();
|
|
113
|
+
const visit = (key) => {
|
|
114
|
+
if (seen.has(key)) return;
|
|
115
|
+
seen.add(key);
|
|
116
|
+
if (held?.has(key)) return;
|
|
117
|
+
const value = addressed.values.get(key);
|
|
118
|
+
send.push(value);
|
|
119
|
+
value.children.forEach(visit);
|
|
120
|
+
};
|
|
121
|
+
addressed.roots.forEach(visit);
|
|
122
|
+
return send;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The value keys a request names (`F-Values`): well-formed keys only, at most `MAX_HELD_VALUES`.
|
|
126
|
+
*
|
|
127
|
+
* A response that leaves values out is one browser's. Integrations must keep it out of shared
|
|
128
|
+
* caches (CDN, ISR, PPR shells), and add `Vary: F-Values` to cacheable responses.
|
|
129
|
+
*/
|
|
130
|
+
function parseHeldValues(input) {
|
|
131
|
+
const header = typeof input === "string" || input == null ? input : (input instanceof Request ? input.headers : input).get(VALUES_HEADER);
|
|
132
|
+
const held = /* @__PURE__ */ new Set();
|
|
133
|
+
if (!header) return held;
|
|
134
|
+
for (const key of header.split(",", 256)) {
|
|
135
|
+
const trimmed = key.trim();
|
|
136
|
+
if (VALUE_KEY_PATTERN.test(trimmed)) held.add(trimmed);
|
|
137
|
+
if (held.size === 128) break;
|
|
138
|
+
}
|
|
139
|
+
return held;
|
|
140
|
+
}
|
|
141
|
+
/** A value script's JSON, safe inside a `<script>` element. */
|
|
142
|
+
function escapeScriptJson(json) {
|
|
143
|
+
return json.replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
144
|
+
}
|
|
145
|
+
// src/server/seal.ts
|
|
146
|
+
/**
|
|
147
|
+
* Sealing an action's captured values. An inline `"use server"` function that reads a variable of
|
|
148
|
+
* the render around it (`user` below) runs in a later request, so the value makes the round trip
|
|
149
|
+
* through the browser:
|
|
150
|
+
*
|
|
151
|
+
* async function Page() {
|
|
152
|
+
* const user = await getUser();
|
|
153
|
+
* async function rename(form) {
|
|
154
|
+
* "use server";
|
|
155
|
+
* await db.rename(user.id, form.get("name"));
|
|
156
|
+
* }
|
|
157
|
+
* return <form action={rename}>…</form>;
|
|
158
|
+
* }
|
|
159
|
+
*
|
|
160
|
+
* The browser must not read or change it, so it travels sealed: AES-GCM with a fresh random IV,
|
|
161
|
+
* and the action id as additional data, so a sealed value only opens for the action it was made
|
|
162
|
+
* for. Web Crypto only, so it runs on Node, Bun, Deno, workerd and every other runtime with
|
|
163
|
+
* `crypto.subtle`.
|
|
164
|
+
*
|
|
165
|
+
* Sealing is asynchronous. React's server renderer waits for it: a form whose action's values
|
|
166
|
+
* are still sealing suspends, and renders once they are ready.
|
|
167
|
+
*
|
|
168
|
+
* sealed = base64url( version (1 byte) ‖ key id (1 byte) ‖ IV (12 bytes) ‖ ciphertext+tag )
|
|
169
|
+
*/
|
|
170
|
+
const VERSION = 1;
|
|
171
|
+
const IV_BYTES = 12;
|
|
172
|
+
const KEY_BYTES = 32;
|
|
173
|
+
/** Thrown when a sealed value does not open: changed, made for another action, or stale. */
|
|
174
|
+
var SealError = class extends Error {
|
|
175
|
+
/**
|
|
176
|
+
* `stale`: sealed with a key this server does not have, as after a deploy that changed keys.
|
|
177
|
+
* The page should reload. `invalid`: anything else; answer 400.
|
|
178
|
+
*/
|
|
179
|
+
reason;
|
|
180
|
+
constructor(reason, message) {
|
|
181
|
+
super(message);
|
|
182
|
+
this.name = "SealError";
|
|
183
|
+
this.reason = reason;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
function base64url(bytes) {
|
|
187
|
+
let binary = "";
|
|
188
|
+
for (let index = 0; index < bytes.length; index += 32768) binary += String.fromCharCode(...bytes.subarray(index, index + 32768));
|
|
189
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
190
|
+
}
|
|
191
|
+
function fromBase64url(text) {
|
|
192
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
|
|
193
|
+
try {
|
|
194
|
+
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
195
|
+
const bytes = new Uint8Array(binary.length);
|
|
196
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
197
|
+
return bytes;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** A new random key, as base64url: what `FURROW_ACTIONS_KEY` holds. */
|
|
203
|
+
function generateActionKey() {
|
|
204
|
+
return base64url(crypto.getRandomValues(new Uint8Array(KEY_BYTES)));
|
|
205
|
+
}
|
|
206
|
+
async function importKey(secret) {
|
|
207
|
+
const bytes = fromBase64url(secret.trim());
|
|
208
|
+
if (!bytes || bytes.length !== KEY_BYTES) throw new TypeError("an actions key must be 32 random bytes as base64url; create one with generateActionKey() from @farm.js/islx/server");
|
|
209
|
+
return crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
210
|
+
}
|
|
211
|
+
/** The key's id: its first byte of SHA-256, so a value names the key that sealed it. */
|
|
212
|
+
async function keyId(secret) {
|
|
213
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret.trim()));
|
|
214
|
+
return new Uint8Array(digest)[0];
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The keys values are sealed and opened with. `current` seals; `previous` keys still open
|
|
218
|
+
* values, so pages rendered before a key rotation keep working until they are reloaded.
|
|
219
|
+
*
|
|
220
|
+
* Every server that may receive an action must have the same keys. When an app runs more than
|
|
221
|
+
* one instance, or builds and serves on different machines, set them from the environment.
|
|
222
|
+
*/
|
|
223
|
+
async function createActionKeys(current, previous = []) {
|
|
224
|
+
const byId = /* @__PURE__ */ new Map();
|
|
225
|
+
const seen = /* @__PURE__ */ new Set();
|
|
226
|
+
const add = async (secret) => {
|
|
227
|
+
const trimmed = secret.trim();
|
|
228
|
+
if (seen.has(trimmed)) return;
|
|
229
|
+
seen.add(trimmed);
|
|
230
|
+
const id = await keyId(trimmed);
|
|
231
|
+
const key = await importKey(trimmed);
|
|
232
|
+
byId.set(id, [...byId.get(id) ?? [], key]);
|
|
233
|
+
return {
|
|
234
|
+
id,
|
|
235
|
+
key
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
const { id: currentId, key: currentKey } = await add(current);
|
|
239
|
+
for (const secret of previous) await add(secret);
|
|
240
|
+
return {
|
|
241
|
+
current: currentKey,
|
|
242
|
+
currentId,
|
|
243
|
+
byId
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function additionalData(actionId) {
|
|
247
|
+
return new TextEncoder().encode(`islx:action:${actionId}`);
|
|
248
|
+
}
|
|
249
|
+
/** Seals an action's captured values for the browser to carry. */
|
|
250
|
+
async function sealBound(keys, actionId, values, options = {}) {
|
|
251
|
+
let json;
|
|
252
|
+
try {
|
|
253
|
+
json = JSON.stringify(values.map((value, index) => encode(value, {
|
|
254
|
+
encodeFunction: options.encodeFunction,
|
|
255
|
+
path: [options.names?.[index] ?? `$${index}`]
|
|
256
|
+
})));
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error instanceof EncodeError) {
|
|
259
|
+
const name = String(error.path[0]);
|
|
260
|
+
const where = error.path.length > 1 ? ` (at ${error.location})` : "";
|
|
261
|
+
const action = options.label ? ` ${options.label}` : "";
|
|
262
|
+
throw new TypeError(`\`${name}\` cannot be captured by the server action${action}${where}: ${error.reason}. Read it inside the action instead, or capture a plain value.`, { cause: error });
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
267
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({
|
|
268
|
+
name: "AES-GCM",
|
|
269
|
+
iv,
|
|
270
|
+
additionalData: additionalData(actionId)
|
|
271
|
+
}, keys.current, new TextEncoder().encode(json)));
|
|
272
|
+
const sealed = new Uint8Array(14 + ciphertext.length);
|
|
273
|
+
sealed[0] = VERSION;
|
|
274
|
+
sealed[1] = keys.currentId;
|
|
275
|
+
sealed.set(iv, 2);
|
|
276
|
+
sealed.set(ciphertext, 14);
|
|
277
|
+
return base64url(sealed);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Opens values sealed for `actionId`. Throws a `SealError`: `stale` when no key this server has
|
|
281
|
+
* sealed them, `invalid` when they were changed or sealed for another action.
|
|
282
|
+
*/
|
|
283
|
+
async function unsealBound(keys, actionId, sealed, options = {}) {
|
|
284
|
+
const bytes = fromBase64url(sealed);
|
|
285
|
+
if (!bytes || bytes.length < 30) throw new SealError("invalid", "sealed action values are malformed");
|
|
286
|
+
if (bytes[0] !== VERSION) throw new SealError("stale", "sealed action values use another format version");
|
|
287
|
+
const candidates = keys.byId.get(bytes[1]);
|
|
288
|
+
if (!candidates) throw new SealError("stale", "sealed action values use a key this server does not have");
|
|
289
|
+
let plaintext;
|
|
290
|
+
for (const key of candidates) try {
|
|
291
|
+
plaintext = await crypto.subtle.decrypt({
|
|
292
|
+
name: "AES-GCM",
|
|
293
|
+
iv: bytes.subarray(2, 14),
|
|
294
|
+
additionalData: additionalData(actionId)
|
|
295
|
+
}, key, bytes.subarray(14));
|
|
296
|
+
break;
|
|
297
|
+
} catch {}
|
|
298
|
+
if (!plaintext) throw new SealError("invalid", "sealed action values do not open for this action");
|
|
299
|
+
const values = decode(JSON.parse(new TextDecoder().decode(plaintext)), options);
|
|
300
|
+
if (!Array.isArray(values)) throw new SealError("invalid", "sealed action values are malformed");
|
|
301
|
+
return values;
|
|
302
|
+
}
|
|
303
|
+
// src/server/actions.ts
|
|
304
|
+
/**
|
|
305
|
+
* Server actions: functions the browser can call. Each has an id; the compiler registers every
|
|
306
|
+
* export of a `"use server"` module, and hoists each inline `"use server"` function out of the
|
|
307
|
+
* component it is written in, with the variables it reads passed back to it.
|
|
308
|
+
*
|
|
309
|
+
* A reference is what the browser holds: an id, and the action's bound values (the variables an
|
|
310
|
+
* inline action captured, and arguments added with `.bind`) sealed so only the server reads them.
|
|
311
|
+
*
|
|
312
|
+
* Reference functions carry React's progressive-enhancement hooks, so `<form action={fn}>` works
|
|
313
|
+
* before JavaScript loads, and `useActionState` keeps its state across a form post.
|
|
314
|
+
*/
|
|
315
|
+
const REGISTRY_KEY = Symbol.for("islx.actions.registry");
|
|
316
|
+
const REFERENCES_KEY = Symbol.for("islx.actions.references");
|
|
317
|
+
const KEYS_KEY = Symbol.for("islx.actions.keys");
|
|
318
|
+
function registry() {
|
|
319
|
+
return globalThis[REGISTRY_KEY] ??= /* @__PURE__ */ new Map();
|
|
320
|
+
}
|
|
321
|
+
function references() {
|
|
322
|
+
return globalThis[REFERENCES_KEY] ??= /* @__PURE__ */ new WeakMap();
|
|
323
|
+
}
|
|
324
|
+
let warnedGeneratedKey = false;
|
|
325
|
+
/**
|
|
326
|
+
* Sets the keys bound values are sealed with: `key` seals, `previousKeys` still open values
|
|
327
|
+
* sealed before a rotation. Without it, `ISLX_ACTIONS_KEY` and `ISLX_ACTIONS_PREVIOUS_KEYS`
|
|
328
|
+
* (comma-separated) are read from the environment; failing those, the key the bundler plugin
|
|
329
|
+
* made for this build; failing that, a key generated for this process: fine for one long-lived
|
|
330
|
+
* server, wrong for several instances or serverless, where every server must hold the same key.
|
|
331
|
+
*/
|
|
332
|
+
function configureActionKeys(options) {
|
|
333
|
+
globalThis[KEYS_KEY] = createActionKeys(options.key, options.previousKeys);
|
|
334
|
+
}
|
|
335
|
+
function actionKeys() {
|
|
336
|
+
const scope = globalThis;
|
|
337
|
+
if (scope[KEYS_KEY]) return scope[KEYS_KEY];
|
|
338
|
+
const env = globalThis.process?.env;
|
|
339
|
+
const key = env?.ISLX_ACTIONS_KEY;
|
|
340
|
+
if (key) {
|
|
341
|
+
const previous = (env?.ISLX_ACTIONS_PREVIOUS_KEYS ?? "").split(",").filter(Boolean);
|
|
342
|
+
return scope[KEYS_KEY] = createActionKeys(key, previous);
|
|
343
|
+
}
|
|
344
|
+
const buildKey = getBuildDefaults().key;
|
|
345
|
+
if (buildKey) return scope[KEYS_KEY] = createActionKeys(buildKey);
|
|
346
|
+
if (!warnedGeneratedKey && env?.NODE_ENV === "production") {
|
|
347
|
+
warnedGeneratedKey = true;
|
|
348
|
+
console.warn("[islx] ISLX_ACTIONS_KEY is not set: server actions use a key generated for this process. Pages rendered by another instance or before a restart cannot run actions that captured values. Set ISLX_ACTIONS_KEY on every instance (generateActionKey() makes one).");
|
|
349
|
+
}
|
|
350
|
+
return scope[KEYS_KEY] = createActionKeys(generateActionKey());
|
|
351
|
+
}
|
|
352
|
+
function settle(promise) {
|
|
353
|
+
const settled = promise;
|
|
354
|
+
settled.status = "pending";
|
|
355
|
+
settled.then((value) => {
|
|
356
|
+
settled.status = "fulfilled";
|
|
357
|
+
settled.value = value;
|
|
358
|
+
}, (reason) => {
|
|
359
|
+
settled.status = "rejected";
|
|
360
|
+
settled.reason = reason;
|
|
361
|
+
});
|
|
362
|
+
settled.catch(() => {});
|
|
363
|
+
return settled;
|
|
364
|
+
}
|
|
365
|
+
function capturedValues(state) {
|
|
366
|
+
if (!state.captured) return [];
|
|
367
|
+
return state.capturedValues ??= state.captured();
|
|
368
|
+
}
|
|
369
|
+
/** The values a reference seals, captured variables first. */
|
|
370
|
+
function boundValues(state) {
|
|
371
|
+
return [...capturedValues(state), ...state.bound];
|
|
372
|
+
}
|
|
373
|
+
/** The nested references inside `values` whose own values are still sealing. */
|
|
374
|
+
function pendingNested(values) {
|
|
375
|
+
const pending = [];
|
|
376
|
+
const seen = /* @__PURE__ */ new Set();
|
|
377
|
+
const visit = (value) => {
|
|
378
|
+
if (!value || seen.has(value)) return;
|
|
379
|
+
seen.add(value);
|
|
380
|
+
if (typeof value === "function") {
|
|
381
|
+
const nested = references().get(value);
|
|
382
|
+
const sealed = nested && startSeal(nested);
|
|
383
|
+
if (sealed && sealed.status === "pending") pending.push(sealed);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (typeof value !== "object") return;
|
|
387
|
+
if (value instanceof Map) for (const [key, entry] of value) {
|
|
388
|
+
visit(key);
|
|
389
|
+
visit(entry);
|
|
390
|
+
}
|
|
391
|
+
else if (value instanceof Set) for (const entry of value) visit(entry);
|
|
392
|
+
else for (const entry of Object.values(value)) visit(entry);
|
|
393
|
+
};
|
|
394
|
+
values.forEach(visit);
|
|
395
|
+
return pending;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Starts sealing a reference's bound values, if it has any. A captured value that is itself an
|
|
399
|
+
* action reference is sealed first, so the outer seal carries it sealed.
|
|
400
|
+
*/
|
|
401
|
+
function startSeal(state) {
|
|
402
|
+
if (state.sealed !== void 0) return state.sealed;
|
|
403
|
+
if (state.token !== void 0) {
|
|
404
|
+
state.sealed = settle(Promise.resolve(state.token));
|
|
405
|
+
state.sealed.status = "fulfilled";
|
|
406
|
+
state.sealed.value = state.token;
|
|
407
|
+
return state.sealed;
|
|
408
|
+
}
|
|
409
|
+
const values = boundValues(state);
|
|
410
|
+
if (values.length === 0) return state.sealed = null;
|
|
411
|
+
const label = registry().get(state.id)?.label;
|
|
412
|
+
const names = [...state.names, ...state.bound.map((_, index) => `bound argument ${index + 1}`)];
|
|
413
|
+
state.sealed = settle((async () => {
|
|
414
|
+
const keys = await actionKeys();
|
|
415
|
+
for (let pending = pendingNested(values); pending.length > 0; pending = pendingNested(values)) await Promise.all(pending);
|
|
416
|
+
return sealBound(keys, state.id, values, {
|
|
417
|
+
names,
|
|
418
|
+
label,
|
|
419
|
+
encodeFunction: (fn) => {
|
|
420
|
+
const nested = references().get(fn);
|
|
421
|
+
if (!nested) return void 0;
|
|
422
|
+
if (nested === state) throw new TypeError("a server action cannot capture itself");
|
|
423
|
+
return referenceJson(nested.id, readSealed(nested));
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
})());
|
|
427
|
+
return state.sealed;
|
|
428
|
+
}
|
|
429
|
+
function referenceJson(id, sealed) {
|
|
430
|
+
return sealed === void 0 ? {
|
|
431
|
+
["$"]: "a",
|
|
432
|
+
["v"]: id
|
|
433
|
+
} : {
|
|
434
|
+
["$"]: "a",
|
|
435
|
+
["v"]: id,
|
|
436
|
+
["b"]: sealed
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* The sealed values of a reference, synchronously: React reads them while it renders. While
|
|
441
|
+
* they are still sealing this throws the pending promise, which React waits for (the part of the
|
|
442
|
+
* page being rendered suspends, then renders again).
|
|
443
|
+
*/
|
|
444
|
+
function readSealed(state) {
|
|
445
|
+
const sealed = startSeal(state);
|
|
446
|
+
if (!sealed) return void 0;
|
|
447
|
+
if (sealed.status === "fulfilled") return sealed.value;
|
|
448
|
+
if (sealed.status === "rejected") throw sealed.reason;
|
|
449
|
+
throw sealed;
|
|
450
|
+
}
|
|
451
|
+
async function invoke(state, args) {
|
|
452
|
+
const entry = registry().get(state.id);
|
|
453
|
+
if (!entry) throw new Error(`server action ${state.id} is not registered`);
|
|
454
|
+
return runEntry(entry, state.token !== void 0 ? await openToken(state.id, state.token) : boundValues(state), args);
|
|
455
|
+
}
|
|
456
|
+
function runEntry(entry, values, args) {
|
|
457
|
+
if (entry.captured === 0) return entry.fn(...values, ...args);
|
|
458
|
+
return entry.fn(values.slice(0, entry.captured), ...values.slice(entry.captured), ...args);
|
|
459
|
+
}
|
|
460
|
+
/** A reference function: calling it runs the action, on the server too. */
|
|
461
|
+
function createReference(state, base) {
|
|
462
|
+
const reference = base ?? ((...args) => invoke(state, args));
|
|
463
|
+
references().set(reference, state);
|
|
464
|
+
attachHooks(reference, state);
|
|
465
|
+
return reference;
|
|
466
|
+
}
|
|
467
|
+
function attachHooks(reference, state) {
|
|
468
|
+
const define = (name, value) => Object.defineProperty(reference, name, {
|
|
469
|
+
value,
|
|
470
|
+
configurable: true
|
|
471
|
+
});
|
|
472
|
+
define("$$FORM_ACTION", (prefix) => {
|
|
473
|
+
const sealed = readSealed(state);
|
|
474
|
+
const data = new FormData();
|
|
475
|
+
data.append(formFieldName(prefix, "A"), state.id);
|
|
476
|
+
if (sealed !== void 0) data.append(formFieldName(prefix, "B"), sealed);
|
|
477
|
+
return {
|
|
478
|
+
name: `${FORM_REF_PREFIX}${prefix}`,
|
|
479
|
+
action: "",
|
|
480
|
+
method: "POST",
|
|
481
|
+
encType: "multipart/form-data",
|
|
482
|
+
data
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
define("$$IS_SIGNATURE_EQUAL", (id, boundCount) => {
|
|
486
|
+
return id === state.id && boundCount === state.bound.length;
|
|
487
|
+
});
|
|
488
|
+
define("bind", function(_thisArg, ...args) {
|
|
489
|
+
if (state.token !== void 0) throw new TypeError("a server action received from the browser cannot be bound again");
|
|
490
|
+
return createReference({
|
|
491
|
+
id: state.id,
|
|
492
|
+
captured: state.captured,
|
|
493
|
+
capturedValues: state.capturedValues,
|
|
494
|
+
names: state.names,
|
|
495
|
+
bound: [...state.bound, ...args]
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Registers an export of a `"use server"` module. The export itself stays the function it was;
|
|
501
|
+
* it gains the hooks forms need and an id props can name.
|
|
502
|
+
*/
|
|
503
|
+
function registerServerReference(action, id, label = id) {
|
|
504
|
+
if (typeof action !== "function") throw new TypeError(`${label} is exported from a "use server" module but is not a function. A "use server" module may export only async functions.`);
|
|
505
|
+
registry().set(id, {
|
|
506
|
+
fn: action,
|
|
507
|
+
captured: 0,
|
|
508
|
+
label
|
|
509
|
+
});
|
|
510
|
+
if (!references().has(action)) createReference({
|
|
511
|
+
id,
|
|
512
|
+
captured: null,
|
|
513
|
+
names: [],
|
|
514
|
+
bound: []
|
|
515
|
+
}, action);
|
|
516
|
+
return action;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Registers an inline action the compiler hoisted to module scope. It takes the `captured`
|
|
520
|
+
* variables it read as one array, then its own arguments.
|
|
521
|
+
*/
|
|
522
|
+
function registerInlineAction(hoisted, id, captured, label = id) {
|
|
523
|
+
const fn = captured === 0 ? (...args) => hoisted([], ...args) : hoisted;
|
|
524
|
+
registry().set(id, {
|
|
525
|
+
fn,
|
|
526
|
+
captured,
|
|
527
|
+
label
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* The function an inline action stands for where it was written: calling it runs the hoisted
|
|
532
|
+
* action with the captured variables, and passing it to a form or a client component sends them
|
|
533
|
+
* sealed. `capture` is read when first needed, after the component has assigned them.
|
|
534
|
+
*/
|
|
535
|
+
function bindInlineAction(id, capture, names) {
|
|
536
|
+
return createReference({
|
|
537
|
+
id,
|
|
538
|
+
captured: names.length > 0 ? capture : null,
|
|
539
|
+
names,
|
|
540
|
+
bound: []
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/** The reference a function stands for, if it is a server action. */
|
|
544
|
+
function getActionReference(value) {
|
|
545
|
+
if (typeof value !== "function") return void 0;
|
|
546
|
+
const state = references().get(value);
|
|
547
|
+
if (!state) return void 0;
|
|
548
|
+
return {
|
|
549
|
+
id: state.id,
|
|
550
|
+
sealed: () => readSealed(state),
|
|
551
|
+
ready: async () => {
|
|
552
|
+
await startSeal(state);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/** Encodes a server action for JSON (`encode`'s `encodeFunction`), or undefined for any other function. */
|
|
557
|
+
function encodeActionReference(value) {
|
|
558
|
+
const reference = getActionReference(value);
|
|
559
|
+
return reference && referenceJson(reference.id, reference.sealed());
|
|
560
|
+
}
|
|
561
|
+
async function openToken(id, token) {
|
|
562
|
+
return unsealBound(await actionKeys(), id, token, { decodeReference: decodeNestedReference });
|
|
563
|
+
}
|
|
564
|
+
/** A reference inside sealed values: it stays sealed until it runs. */
|
|
565
|
+
function decodeNestedReference(object) {
|
|
566
|
+
if (object["$"] !== "a" || typeof object["v"] !== "string") return void 0;
|
|
567
|
+
const id = object["v"];
|
|
568
|
+
const token = object["b"];
|
|
569
|
+
if (token !== void 0 && typeof token !== "string") return void 0;
|
|
570
|
+
return createReference({
|
|
571
|
+
id,
|
|
572
|
+
captured: null,
|
|
573
|
+
names: [],
|
|
574
|
+
bound: [],
|
|
575
|
+
token
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Decodes an action reference the browser sent back (an argument): an id this server registered,
|
|
580
|
+
* whose sealed values open when it runs. Unknown ids are refused.
|
|
581
|
+
*/
|
|
582
|
+
function decodeActionReference(object) {
|
|
583
|
+
if (object["$"] !== "a" || typeof object["v"] !== "string") return void 0;
|
|
584
|
+
if (!registry().has(object["v"])) return void 0;
|
|
585
|
+
return decodeNestedReference(object);
|
|
586
|
+
}
|
|
587
|
+
/** Finds the action an id names, and opens its sealed values. */
|
|
588
|
+
async function resolveAction(id, token) {
|
|
589
|
+
const entry = registry().get(id);
|
|
590
|
+
if (!entry) return {
|
|
591
|
+
ok: false,
|
|
592
|
+
reason: "unknown"
|
|
593
|
+
};
|
|
594
|
+
let values = [];
|
|
595
|
+
if (token !== void 0) try {
|
|
596
|
+
values = await openToken(id, token);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (error instanceof SealError) return {
|
|
599
|
+
ok: false,
|
|
600
|
+
reason: error.reason
|
|
601
|
+
};
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
if (values.length < entry.captured) return {
|
|
605
|
+
ok: false,
|
|
606
|
+
reason: "invalid"
|
|
607
|
+
};
|
|
608
|
+
return {
|
|
609
|
+
ok: true,
|
|
610
|
+
boundCount: values.length - entry.captured,
|
|
611
|
+
label: entry.label,
|
|
612
|
+
run: async (args) => runEntry(entry, values, args)
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
export { hashValue as _, getActionReference as a, resolveAction as c, generateActionKey as d, sealBound as f, escapeScriptJson as g, addressValues as h, encodeActionReference as i, SealError as l, VALUE_MIN_LENGTH as m, configureActionKeys as n, registerInlineAction as o, unsealBound as p, decodeActionReference as r, registerServerReference as s, bindInlineAction as t, createActionKeys as u, parseHeldValues as v, selectValuesToSend as y };
|
|
@@ -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 };
|