@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { r as EncodeOptions, t as DecodeOptions } from "./_chunks/codec-CK8kZp4u.mjs";
|
|
2
|
+
import { i as handleAction, n as HandleActionOptions, r as ReactFormState, t as ActionErrorOutcome } from "./_chunks/handle-C0N9ZyNV.mjs";
|
|
3
|
+
import { a as setServerHooks, i as getServerHooks, n as ServerHooks, r as defineServerHooks, t as ActionInfo } from "./_chunks/hooks-3VZV8f_d.mjs";
|
|
4
|
+
|
|
5
|
+
// src/server/values.d.ts
|
|
6
|
+
/** Shorter values stay inline: a reference and its script cost about 60 bytes. */
|
|
7
|
+
declare const VALUE_MIN_LENGTH = 512;
|
|
8
|
+
/** A 64-bit hash of a value's JSON, as 14 base-36 characters. Not cryptographic. */
|
|
9
|
+
declare function hashValue(json: string): string;
|
|
10
|
+
interface AddressedValue {
|
|
11
|
+
key: string;
|
|
12
|
+
json: string;
|
|
13
|
+
/** Keys of the values this value's JSON refers to. */
|
|
14
|
+
children: string[];
|
|
15
|
+
}
|
|
16
|
+
interface AddressedProps {
|
|
17
|
+
/** The props as JSON, with each large value replaced by a reference. */
|
|
18
|
+
json: string;
|
|
19
|
+
values: Map<string, AddressedValue>;
|
|
20
|
+
/** Keys the props refer to directly. */
|
|
21
|
+
roots: string[];
|
|
22
|
+
}
|
|
23
|
+
interface AddressOptions {
|
|
24
|
+
/** Values whose JSON is shorter stay inline. */
|
|
25
|
+
minLength?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Encodes what plain JSON cannot hold: a function (an action), an element (a slot). Return
|
|
28
|
+
* undefined to fall back to the codec, which throws for functions.
|
|
29
|
+
*/
|
|
30
|
+
encodeSpecial?: (value: unknown) => unknown;
|
|
31
|
+
/**
|
|
32
|
+
* The input is JSON already, with its own tags (a server tree's payload): no type is tagged and
|
|
33
|
+
* no object is wrapped, values are only addressed.
|
|
34
|
+
*/
|
|
35
|
+
encoded?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Serializes props with every value whose JSON reaches `minLength` replaced by a reference to it.
|
|
39
|
+
* Types JSON cannot hold are tagged first (the codec), so a `Map` or a `Date` is addressed like
|
|
40
|
+
* any other value. Inner values are addressed first, so a large value that differs between pages
|
|
41
|
+
* in one place still shares its unchanged parts.
|
|
42
|
+
*/
|
|
43
|
+
declare function addressValues(props: Record<string, unknown>, {
|
|
44
|
+
minLength,
|
|
45
|
+
encodeSpecial,
|
|
46
|
+
encoded
|
|
47
|
+
}?: AddressOptions): AddressedProps;
|
|
48
|
+
/**
|
|
49
|
+
* The values a response sends for `addressed`, parents first. A value the browser holds is left
|
|
50
|
+
* out with everything inside it: the browser kept those when it read the value.
|
|
51
|
+
*/
|
|
52
|
+
declare function selectValuesToSend(addressed: AddressedProps, held: ReadonlySet<string> | null): AddressedValue[];
|
|
53
|
+
/**
|
|
54
|
+
* The value keys a request names (`F-Values`): well-formed keys only, at most `MAX_HELD_VALUES`.
|
|
55
|
+
*
|
|
56
|
+
* A response that leaves values out is one browser's. Integrations must keep it out of shared
|
|
57
|
+
* caches (CDN, ISR, PPR shells), and add `Vary: F-Values` to cacheable responses.
|
|
58
|
+
*/
|
|
59
|
+
declare function parseHeldValues(input: Request | Headers | string | null | undefined): ReadonlySet<string>;
|
|
60
|
+
/** A value script's JSON, safe inside a `<script>` element. */
|
|
61
|
+
declare function escapeScriptJson(json: string): string;
|
|
62
|
+
/** Thrown when a sealed value does not open: changed, made for another action, or stale. */
|
|
63
|
+
declare class SealError extends Error {
|
|
64
|
+
/**
|
|
65
|
+
* `stale`: sealed with a key this server does not have, as after a deploy that changed keys.
|
|
66
|
+
* The page should reload. `invalid`: anything else; answer 400.
|
|
67
|
+
*/
|
|
68
|
+
readonly reason: "stale" | "invalid";
|
|
69
|
+
constructor(reason: "stale" | "invalid", message: string);
|
|
70
|
+
}
|
|
71
|
+
interface ActionKeys {
|
|
72
|
+
/** The key new values are sealed with. */
|
|
73
|
+
current: CryptoKey;
|
|
74
|
+
/** Its id, written into each sealed value (0-255). */
|
|
75
|
+
currentId: number;
|
|
76
|
+
/**
|
|
77
|
+
* Every key that opens a value, by id: the current one and earlier ones still accepted. Ids are
|
|
78
|
+
* one byte, so two keys can share one; each is tried.
|
|
79
|
+
*/
|
|
80
|
+
byId: ReadonlyMap<number, readonly CryptoKey[]>;
|
|
81
|
+
}
|
|
82
|
+
/** A new random key, as base64url: what `FURROW_ACTIONS_KEY` holds. */
|
|
83
|
+
declare function generateActionKey(): string;
|
|
84
|
+
/**
|
|
85
|
+
* The keys values are sealed and opened with. `current` seals; `previous` keys still open
|
|
86
|
+
* values, so pages rendered before a key rotation keep working until they are reloaded.
|
|
87
|
+
*
|
|
88
|
+
* Every server that may receive an action must have the same keys. When an app runs more than
|
|
89
|
+
* one instance, or builds and serves on different machines, set them from the environment.
|
|
90
|
+
*/
|
|
91
|
+
declare function createActionKeys(current: string, previous?: readonly string[]): Promise<ActionKeys>;
|
|
92
|
+
interface SealOptions extends Pick<EncodeOptions, "encodeFunction"> {
|
|
93
|
+
/**
|
|
94
|
+
* The captured variables' names, in order, for error messages: "`db` cannot be captured by the
|
|
95
|
+
* server action rename (src/page.tsx): class instances (Pool) are not serializable".
|
|
96
|
+
*/
|
|
97
|
+
names?: readonly string[];
|
|
98
|
+
/** Names the action in error messages. */
|
|
99
|
+
label?: string;
|
|
100
|
+
}
|
|
101
|
+
/** Seals an action's captured values for the browser to carry. */
|
|
102
|
+
declare function sealBound(keys: ActionKeys, actionId: string, values: readonly unknown[], options?: SealOptions): Promise<string>;
|
|
103
|
+
/**
|
|
104
|
+
* Opens values sealed for `actionId`. Throws a `SealError`: `stale` when no key this server has
|
|
105
|
+
* sealed them, `invalid` when they were changed or sealed for another action.
|
|
106
|
+
*/
|
|
107
|
+
declare function unsealBound(keys: ActionKeys, actionId: string, sealed: string, options?: Pick<DecodeOptions, "decodeReference">): Promise<unknown[]>;
|
|
108
|
+
type AnyFunction = (...args: any[]) => any;
|
|
109
|
+
/**
|
|
110
|
+
* Sets the keys bound values are sealed with: `key` seals, `previousKeys` still open values
|
|
111
|
+
* sealed before a rotation. Without it, `ISLX_ACTIONS_KEY` and `ISLX_ACTIONS_PREVIOUS_KEYS`
|
|
112
|
+
* (comma-separated) are read from the environment; failing those, the key the bundler plugin
|
|
113
|
+
* made for this build; failing that, a key generated for this process: fine for one long-lived
|
|
114
|
+
* server, wrong for several instances or serverless, where every server must hold the same key.
|
|
115
|
+
*/
|
|
116
|
+
declare function configureActionKeys(options: {
|
|
117
|
+
key: string;
|
|
118
|
+
previousKeys?: readonly string[];
|
|
119
|
+
}): void;
|
|
120
|
+
/**
|
|
121
|
+
* Registers an export of a `"use server"` module. The export itself stays the function it was;
|
|
122
|
+
* it gains the hooks forms need and an id props can name.
|
|
123
|
+
*/
|
|
124
|
+
declare function registerServerReference<T>(action: T, id: string, label?: string): T;
|
|
125
|
+
/**
|
|
126
|
+
* Registers an inline action the compiler hoisted to module scope. It takes the `captured`
|
|
127
|
+
* variables it read as one array, then its own arguments.
|
|
128
|
+
*/
|
|
129
|
+
declare function registerInlineAction(hoisted: AnyFunction, id: string, captured: number, label?: string): void;
|
|
130
|
+
/**
|
|
131
|
+
* The function an inline action stands for where it was written: calling it runs the hoisted
|
|
132
|
+
* action with the captured variables, and passing it to a form or a client component sends them
|
|
133
|
+
* sealed. `capture` is read when first needed, after the component has assigned them.
|
|
134
|
+
*/
|
|
135
|
+
declare function bindInlineAction(id: string, capture: () => unknown[], names: readonly string[]): AnyFunction;
|
|
136
|
+
/** What a reference travels as: `{"$":"a","v":id}` plus `"b"` when it has sealed values. */
|
|
137
|
+
interface ActionReference {
|
|
138
|
+
id: string;
|
|
139
|
+
/** The sealed values, or undefined. Throws a pending promise while they are sealing. */
|
|
140
|
+
sealed(): string | undefined;
|
|
141
|
+
/** Resolves once the sealed values are ready. */
|
|
142
|
+
ready(): Promise<void>;
|
|
143
|
+
}
|
|
144
|
+
/** The reference a function stands for, if it is a server action. */
|
|
145
|
+
declare function getActionReference(value: unknown): ActionReference | undefined;
|
|
146
|
+
/** Encodes a server action for JSON (`encode`'s `encodeFunction`), or undefined for any other function. */
|
|
147
|
+
declare function encodeActionReference(value: Function): Record<string, unknown> | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* Decodes an action reference the browser sent back (an argument): an id this server registered,
|
|
150
|
+
* whose sealed values open when it runs. Unknown ids are refused.
|
|
151
|
+
*/
|
|
152
|
+
declare function decodeActionReference(object: Record<string, unknown>): AnyFunction | undefined;
|
|
153
|
+
/** The outcome of resolving a posted action. */
|
|
154
|
+
type ResolvedAction = {
|
|
155
|
+
ok: true;
|
|
156
|
+
run(args: unknown[]): Promise<unknown>;
|
|
157
|
+
boundCount: number;
|
|
158
|
+
label: string;
|
|
159
|
+
} | {
|
|
160
|
+
ok: false;
|
|
161
|
+
reason: "unknown" | "stale" | "invalid";
|
|
162
|
+
};
|
|
163
|
+
/** Finds the action an id names, and opens its sealed values. */
|
|
164
|
+
declare function resolveAction(id: string, token: string | undefined): Promise<ResolvedAction>;
|
|
165
|
+
/**
|
|
166
|
+
* Whether a state-changing request came from the app's own origin. Server actions run only for
|
|
167
|
+
* same-origin requests, or requests from an origin the app allows.
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* Whether `origin` matches an allowed pattern: an origin (`https://app.example.com`), a host
|
|
171
|
+
* (`app.example.com`), or a subdomain wildcard (`*.example.com`, `https://*.example.com:8443`).
|
|
172
|
+
*/
|
|
173
|
+
declare function matchesAllowedOrigin(origin: string, pattern: string): boolean;
|
|
174
|
+
/** Validates and canonicalizes an allowed-origin pattern; throws for anything else. */
|
|
175
|
+
declare function normalizeAllowedOrigin(value: string): string;
|
|
176
|
+
/**
|
|
177
|
+
* Whether the request may run a server action: it names its origin (or `Sec-Fetch-Site:
|
|
178
|
+
* same-origin`), and that origin is the request's own or an allowed one. Cross-site requests
|
|
179
|
+
* run only from an allowed origin.
|
|
180
|
+
*/
|
|
181
|
+
declare function isSameOriginRequest(request: Request, allowedOrigins: readonly string[]): boolean;
|
|
182
|
+
/**
|
|
183
|
+
* What a bundler plugin knows about the build, for the server runtime: the browser entry that
|
|
184
|
+
* hydrates islands, and the key a build seals action values with when the environment sets none.
|
|
185
|
+
* Set from the plugin's runtime module, which every module with islands or actions imports.
|
|
186
|
+
*/
|
|
187
|
+
interface BuildDefaults {
|
|
188
|
+
/** The URL of the browser entry `renderToStream` adds to pages. */
|
|
189
|
+
entry?: string;
|
|
190
|
+
/** A key made for this build: every instance of one build shares it. */
|
|
191
|
+
key?: string;
|
|
192
|
+
/**
|
|
193
|
+
* The browser files each client module needs (its chunk first, then the chunks it imports), by
|
|
194
|
+
* the module's path from the root: an island preloads them, so they load alongside the entry.
|
|
195
|
+
*/
|
|
196
|
+
modules?: Record<string, string[]>;
|
|
197
|
+
}
|
|
198
|
+
declare function setBuildDefaults(defaults: BuildDefaults): void;
|
|
199
|
+
declare function getBuildDefaults(): BuildDefaults;
|
|
200
|
+
export { type ActionErrorOutcome, type ActionInfo, type ActionKeys, type ActionReference, type AddressOptions, type AddressedProps, type AddressedValue, type BuildDefaults, type HandleActionOptions, type ReactFormState, type ResolvedAction, SealError, type SealOptions, type ServerHooks, VALUE_MIN_LENGTH, addressValues, bindInlineAction, configureActionKeys, createActionKeys, decodeActionReference, defineServerHooks, encodeActionReference, escapeScriptJson, generateActionKey, getActionReference, getBuildDefaults, getServerHooks, handleAction, hashValue, isSameOriginRequest, matchesAllowedOrigin, normalizeAllowedOrigin, parseHeldValues, registerInlineAction, registerServerReference, resolveAction, sealBound, selectValuesToSend, setBuildDefaults, setServerHooks, unsealBound };
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { _ as hashValue, a as getActionReference, c as resolveAction, d as generateActionKey, f as sealBound, g as escapeScriptJson, h as addressValues, i as encodeActionReference, l as SealError, m as VALUE_MIN_LENGTH, n as configureActionKeys, o as registerInlineAction, p as unsealBound, r as decodeActionReference, s as registerServerReference, t as bindInlineAction, u as createActionKeys, v as parseHeldValues, y as selectValuesToSend } from "./_chunks/actions-C4dZ1-XD.mjs";
|
|
2
|
+
import { a as encode, r as decode } from "./_chunks/codec-BTDkzzPP.mjs";
|
|
3
|
+
import { F as stripRuntimeFields, P as readPostedAction, j as attachmentField, n as ACTION_KEY_FIELD, o as BOUND_FIELD, r as ACTION_STALE_HEADER, s as CALL_FIELD, t as ACTION_HEADER } from "./_chunks/wire-CXOodE06.mjs";
|
|
4
|
+
import { n as setBuildDefaults, t as getBuildDefaults } from "./_chunks/build-COIcs7wm.mjs";
|
|
5
|
+
// src/server/hooks.ts
|
|
6
|
+
const HOOKS_KEY = Symbol.for("islx.server.hooks");
|
|
7
|
+
/** Types an app's server hooks. */
|
|
8
|
+
function defineServerHooks(hooks) {
|
|
9
|
+
return hooks;
|
|
10
|
+
}
|
|
11
|
+
/** Installs the app's hooks; the plugin's server runtime calls it. */
|
|
12
|
+
function setServerHooks(hooks) {
|
|
13
|
+
globalThis[HOOKS_KEY] = hooks;
|
|
14
|
+
}
|
|
15
|
+
function getServerHooks() {
|
|
16
|
+
return globalThis[HOOKS_KEY] ?? {};
|
|
17
|
+
}
|
|
18
|
+
/** Splits a registry label, `save (src/actions.ts)`, into name and module. */
|
|
19
|
+
function describeAction(id, label) {
|
|
20
|
+
const match = /^(.*) \((.*)\)$/.exec(label ?? "");
|
|
21
|
+
return match ? {
|
|
22
|
+
id,
|
|
23
|
+
name: match[1],
|
|
24
|
+
module: match[2]
|
|
25
|
+
} : {
|
|
26
|
+
id,
|
|
27
|
+
name: label ?? id,
|
|
28
|
+
module: ""
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// src/server/body.ts
|
|
32
|
+
/** Reading a request body within a size limit. */
|
|
33
|
+
/** The body was larger than the limit. */
|
|
34
|
+
var BodyTooLargeError = class extends Error {
|
|
35
|
+
/** The bytes read before the limit was passed, for callers that peek at the start. */
|
|
36
|
+
head;
|
|
37
|
+
constructor(head) {
|
|
38
|
+
super("request body is too large");
|
|
39
|
+
this.name = "BodyTooLargeError";
|
|
40
|
+
this.head = head;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function concat(chunks, total) {
|
|
44
|
+
const body = new Uint8Array(total);
|
|
45
|
+
let offset = 0;
|
|
46
|
+
for (const chunk of chunks) {
|
|
47
|
+
body.set(chunk, offset);
|
|
48
|
+
offset += chunk.byteLength;
|
|
49
|
+
}
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reads `request`'s body, stopping as soon as it passes `limit` bytes: a lying or missing
|
|
54
|
+
* `Content-Length` cannot make the server buffer more.
|
|
55
|
+
*/
|
|
56
|
+
async function readBody(request, limit) {
|
|
57
|
+
const length = request.headers.get("content-length")?.trim();
|
|
58
|
+
if (length && /^\d+$/.test(length) && Number(length) > limit) throw new BodyTooLargeError(/* @__PURE__ */ new Uint8Array());
|
|
59
|
+
if (!request.body) return /* @__PURE__ */ new Uint8Array();
|
|
60
|
+
const reader = request.body.getReader();
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let total = 0;
|
|
63
|
+
try {
|
|
64
|
+
while (true) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done) break;
|
|
67
|
+
if (!value) continue;
|
|
68
|
+
chunks.push(value);
|
|
69
|
+
total += value.byteLength;
|
|
70
|
+
if (total > limit) {
|
|
71
|
+
reader.cancel().catch(() => {});
|
|
72
|
+
throw new BodyTooLargeError(concat(chunks, total));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
}
|
|
78
|
+
return concat(chunks, total);
|
|
79
|
+
}
|
|
80
|
+
/** Parses form bytes with the request's content type, as `Request#formData` would. */
|
|
81
|
+
async function parseForm(request, body) {
|
|
82
|
+
return new Request(request.url, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
|
85
|
+
body
|
|
86
|
+
}).formData();
|
|
87
|
+
}
|
|
88
|
+
// src/server/origin.ts
|
|
89
|
+
/**
|
|
90
|
+
* Whether a state-changing request came from the app's own origin. Server actions run only for
|
|
91
|
+
* same-origin requests, or requests from an origin the app allows.
|
|
92
|
+
*/
|
|
93
|
+
/** The origin a request claims to come from (Origin, then Referer), or why it has none. */
|
|
94
|
+
function sourceOrigin(request) {
|
|
95
|
+
const value = request.headers.get("origin")?.trim() || request.headers.get("referer")?.trim() || null;
|
|
96
|
+
if (!value) return { origin: null };
|
|
97
|
+
if (value === "null") return { invalid: true };
|
|
98
|
+
try {
|
|
99
|
+
const parsed = new URL(value);
|
|
100
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return { invalid: true };
|
|
101
|
+
return { origin: parsed.origin };
|
|
102
|
+
} catch {
|
|
103
|
+
return { invalid: true };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The source matches the `Host` header and the request URL's scheme. The scheme check blocks a
|
|
108
|
+
* protocol-downgrade request; behind a TLS-terminating proxy that rebuilds `request.url` as
|
|
109
|
+
* `http:`, list the browser origin in `allowedOrigins`.
|
|
110
|
+
*/
|
|
111
|
+
function matchesHost(origin, request) {
|
|
112
|
+
const host = request.headers.get("host")?.trim().toLowerCase();
|
|
113
|
+
if (!host) return false;
|
|
114
|
+
try {
|
|
115
|
+
const source = new URL(origin);
|
|
116
|
+
return source.protocol === new URL(request.url).protocol && source.host.toLowerCase() === host;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function effectivePort(url) {
|
|
122
|
+
if (url.port) return url.port;
|
|
123
|
+
return url.protocol === "https:" ? "443" : url.protocol === "http:" ? "80" : "";
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Whether `origin` matches an allowed pattern: an origin (`https://app.example.com`), a host
|
|
127
|
+
* (`app.example.com`), or a subdomain wildcard (`*.example.com`, `https://*.example.com:8443`).
|
|
128
|
+
*/
|
|
129
|
+
function matchesAllowedOrigin(origin, pattern) {
|
|
130
|
+
const source = new URL(origin);
|
|
131
|
+
if (!pattern.includes("*")) return pattern.includes("://") ? source.origin === pattern : source.host === pattern;
|
|
132
|
+
const schemeEnd = pattern.indexOf("://");
|
|
133
|
+
const scheme = schemeEnd === -1 ? null : pattern.slice(0, schemeEnd + 1);
|
|
134
|
+
const hostPattern = pattern.slice(schemeEnd === -1 ? 0 : schemeEnd + 3);
|
|
135
|
+
const separator = hostPattern.lastIndexOf(":");
|
|
136
|
+
const [wildcardHost, port] = separator === -1 ? [hostPattern, null] : [hostPattern.slice(0, separator), hostPattern.slice(separator + 1)];
|
|
137
|
+
const baseHost = wildcardHost.slice(2);
|
|
138
|
+
if (scheme && source.protocol !== scheme) return false;
|
|
139
|
+
if (port ? effectivePort(source) !== port : source.port) return false;
|
|
140
|
+
return source.hostname.endsWith(`.${baseHost}`) && source.hostname !== baseHost;
|
|
141
|
+
}
|
|
142
|
+
/** Validates and canonicalizes an allowed-origin pattern; throws for anything else. */
|
|
143
|
+
function normalizeAllowedOrigin(value) {
|
|
144
|
+
const pattern = value.trim().toLowerCase();
|
|
145
|
+
if (!pattern) throw new TypeError("allowedOrigins cannot contain empty values");
|
|
146
|
+
if (pattern.includes("*")) {
|
|
147
|
+
if (!/^(?:https?:\/\/)?\*\.[a-z0-9.-]+(?::\d+)?$/.test(pattern)) throw new TypeError(`Invalid allowedOrigins pattern: ${JSON.stringify(value)}`);
|
|
148
|
+
return pattern;
|
|
149
|
+
}
|
|
150
|
+
if (pattern.includes("://")) {
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = new URL(pattern);
|
|
154
|
+
} catch {
|
|
155
|
+
throw new TypeError(`Invalid allowedOrigins value: ${JSON.stringify(value)}`);
|
|
156
|
+
}
|
|
157
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new TypeError(`allowedOrigins must hold origins without paths: ${JSON.stringify(value)}`);
|
|
158
|
+
return parsed.origin;
|
|
159
|
+
}
|
|
160
|
+
if (/[/@?#]/.test(pattern)) throw new TypeError(`allowedOrigins must hold origins or hosts: ${JSON.stringify(value)}`);
|
|
161
|
+
try {
|
|
162
|
+
return new URL(`http://${pattern}`).host;
|
|
163
|
+
} catch {
|
|
164
|
+
throw new TypeError(`Invalid allowedOrigins value: ${JSON.stringify(value)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether the request may run a server action: it names its origin (or `Sec-Fetch-Site:
|
|
169
|
+
* same-origin`), and that origin is the request's own or an allowed one. Cross-site requests
|
|
170
|
+
* run only from an allowed origin.
|
|
171
|
+
*/
|
|
172
|
+
function isSameOriginRequest(request, allowedOrigins) {
|
|
173
|
+
const source = sourceOrigin(request);
|
|
174
|
+
if ("invalid" in source) return false;
|
|
175
|
+
const fetchSite = request.headers.get("sec-fetch-site")?.trim().toLowerCase();
|
|
176
|
+
if (!source.origin) return fetchSite === "same-origin";
|
|
177
|
+
const origin = source.origin;
|
|
178
|
+
const allowed = allowedOrigins.some((pattern) => matchesAllowedOrigin(origin, pattern));
|
|
179
|
+
if (!allowed && origin !== new URL(request.url).origin && !matchesHost(origin, request)) return false;
|
|
180
|
+
return fetchSite !== "cross-site" || allowed;
|
|
181
|
+
}
|
|
182
|
+
// src/server/handle.ts
|
|
183
|
+
/**
|
|
184
|
+
* Handling a posted server action: one function on a Web `Request`, so any server mounts it.
|
|
185
|
+
* It returns `null` for a request that is not an action, which the app then handles as usual.
|
|
186
|
+
*
|
|
187
|
+
* Calls from JavaScript (`F-Action` header) answer JSON. Forms posted without JavaScript run the
|
|
188
|
+
* action and answer `303 See Other` back to the page, or render the page with a
|
|
189
|
+
* `useActionState` result when the app gives `renderFormState`.
|
|
190
|
+
*/
|
|
191
|
+
const DEFAULT_BODY_SIZE_LIMIT = 1e6;
|
|
192
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
193
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{1,1000000}$/;
|
|
194
|
+
function text(status, body, headers = {}) {
|
|
195
|
+
return new Response(body, {
|
|
196
|
+
status,
|
|
197
|
+
headers: {
|
|
198
|
+
"cache-control": "no-store",
|
|
199
|
+
"content-type": "text/plain; charset=utf-8",
|
|
200
|
+
"x-content-type-options": "nosniff",
|
|
201
|
+
...headers
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function json(body, headers = {}) {
|
|
206
|
+
return new Response(JSON.stringify(body), {
|
|
207
|
+
status: 200,
|
|
208
|
+
headers: {
|
|
209
|
+
"cache-control": "no-store",
|
|
210
|
+
"content-type": "application/json",
|
|
211
|
+
...headers
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function seeOther(location) {
|
|
216
|
+
return new Response(null, {
|
|
217
|
+
status: 303,
|
|
218
|
+
headers: {
|
|
219
|
+
"cache-control": "no-store",
|
|
220
|
+
location
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const STALE = { [ACTION_STALE_HEADER]: "1" };
|
|
225
|
+
/** A reference from the browser: only actions, and only ids this server registered. */
|
|
226
|
+
function decodeArgumentReference(object) {
|
|
227
|
+
if (object["$"] === "r" || object["$"] === "s") return void 0;
|
|
228
|
+
return decodeActionReference(object);
|
|
229
|
+
}
|
|
230
|
+
function looksLikeActionForm(head) {
|
|
231
|
+
const start = new TextDecoder().decode(head);
|
|
232
|
+
return start.includes("$F_REF_") || start.includes(`%24F_REF_`);
|
|
233
|
+
}
|
|
234
|
+
/** Runs the server action a request posts, or returns null when it posts none. */
|
|
235
|
+
async function handleAction(request, options = {}) {
|
|
236
|
+
if (request.method.toUpperCase() !== "POST") return null;
|
|
237
|
+
const allowedOrigins = (options.allowedOrigins ?? []).map(normalizeAllowedOrigin);
|
|
238
|
+
const limit = options.bodySizeLimit ?? DEFAULT_BODY_SIZE_LIMIT;
|
|
239
|
+
const run = options.run ?? ((_request, action) => action());
|
|
240
|
+
const hooks = getServerHooks();
|
|
241
|
+
/** Runs a resolved action through the app's hooks; maps what it throws. */
|
|
242
|
+
const execute = async (resolved, id, args) => {
|
|
243
|
+
const info = {
|
|
244
|
+
request,
|
|
245
|
+
...describeAction(id, resolved.label)
|
|
246
|
+
};
|
|
247
|
+
const invoke = () => resolved.run(args);
|
|
248
|
+
try {
|
|
249
|
+
return { result: await run(request, hooks.action ? () => hooks.action(info, invoke) : invoke) };
|
|
250
|
+
} catch (error) {
|
|
251
|
+
const outcome = options.mapError?.(error) ?? hooks.actionError?.(error, info);
|
|
252
|
+
if (!outcome?.redirect && !outcome?.status) {
|
|
253
|
+
if (options.onError) options.onError(error);
|
|
254
|
+
else if (hooks.error) hooks.error(error, info);
|
|
255
|
+
else console.error("[islx] Server action failed:", error);
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
error,
|
|
259
|
+
outcome
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const id = request.headers.get(ACTION_HEADER);
|
|
264
|
+
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
265
|
+
if (id !== null) {
|
|
266
|
+
if (!isSameOriginRequest(request, allowedOrigins)) return text(403, "Forbidden");
|
|
267
|
+
if (!ID_PATTERN.test(id)) return text(400, "Bad Request");
|
|
268
|
+
let body;
|
|
269
|
+
try {
|
|
270
|
+
body = await readBody(request, limit);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error instanceof BodyTooLargeError) return text(413, "Payload Too Large");
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
let args;
|
|
276
|
+
let token;
|
|
277
|
+
try {
|
|
278
|
+
const form = contentType.startsWith("multipart/form-data") ? await parseForm(request, body) : null;
|
|
279
|
+
const call = form?.get(CALL_FIELD);
|
|
280
|
+
if (form && typeof call !== "string") {
|
|
281
|
+
const bound = form.get(BOUND_FIELD);
|
|
282
|
+
token = typeof bound === "string" ? bound : void 0;
|
|
283
|
+
form.delete(BOUND_FIELD);
|
|
284
|
+
args = [form];
|
|
285
|
+
} else if (form) {
|
|
286
|
+
const payload = JSON.parse(call);
|
|
287
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new TypeError();
|
|
288
|
+
const decodeReference = (object) => {
|
|
289
|
+
const index = object["v"];
|
|
290
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0) return void 0;
|
|
291
|
+
if (object["$"] === "K") {
|
|
292
|
+
const file = form.get(attachmentField(index));
|
|
293
|
+
return typeof file === "string" || file === null ? void 0 : file;
|
|
294
|
+
}
|
|
295
|
+
if (object["$"] === "F") {
|
|
296
|
+
const prefix = attachmentField(index, "");
|
|
297
|
+
const data = new FormData();
|
|
298
|
+
for (const [key, value] of form) if (key.startsWith(prefix)) data.append(key.slice(prefix.length), value);
|
|
299
|
+
return data;
|
|
300
|
+
}
|
|
301
|
+
return decodeArgumentReference(object);
|
|
302
|
+
};
|
|
303
|
+
const decoded = decode(payload.a ?? [], { decodeReference });
|
|
304
|
+
if (!Array.isArray(decoded)) throw new TypeError("arguments must be an array");
|
|
305
|
+
if (payload.b !== void 0 && typeof payload.b !== "string") throw new TypeError();
|
|
306
|
+
args = decoded;
|
|
307
|
+
token = payload.b;
|
|
308
|
+
} else {
|
|
309
|
+
const payload = JSON.parse(new TextDecoder().decode(body) || "{}");
|
|
310
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new TypeError();
|
|
311
|
+
const decoded = decode(payload.a ?? [], { decodeReference: decodeArgumentReference });
|
|
312
|
+
if (!Array.isArray(decoded)) throw new TypeError("arguments must be an array");
|
|
313
|
+
if (payload.b !== void 0 && typeof payload.b !== "string") throw new TypeError();
|
|
314
|
+
args = decoded;
|
|
315
|
+
token = payload.b;
|
|
316
|
+
}
|
|
317
|
+
if (token !== void 0 && !TOKEN_PATTERN.test(token)) throw new TypeError();
|
|
318
|
+
} catch {
|
|
319
|
+
return text(400, "Bad Request");
|
|
320
|
+
}
|
|
321
|
+
const resolved = await resolveAction(id, token);
|
|
322
|
+
if (!resolved.ok) return resolved.reason === "invalid" ? text(400, "Bad Request") : text(resolved.reason === "unknown" ? 404 : 409, "Stale Action", STALE);
|
|
323
|
+
const ran = await execute(resolved, id, args);
|
|
324
|
+
if ("error" in ran) {
|
|
325
|
+
if (ran.outcome?.redirect) return json({ redirect: ran.outcome.redirect });
|
|
326
|
+
if (ran.outcome?.status) return text(ran.outcome.status, "Server action failed");
|
|
327
|
+
return text(500, "Server action failed");
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
return json({ result: encode(ran.result, { encodeFunction: encodeActionReference }) });
|
|
331
|
+
} catch (error) {
|
|
332
|
+
(options.onError ?? ((reason) => console.error("[islx] Server action failed:", reason)))(/* @__PURE__ */ new TypeError(`the server action's result is not serializable: ${error.message}`));
|
|
333
|
+
return text(500, "Server action failed");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!contentType.startsWith("multipart/form-data") && !contentType.startsWith("application/x-www-form-urlencoded")) return null;
|
|
337
|
+
let body;
|
|
338
|
+
try {
|
|
339
|
+
body = await readBody(request.clone(), limit);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error instanceof BodyTooLargeError) return looksLikeActionForm(error.head) ? text(413, "Payload Too Large") : null;
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
let form;
|
|
345
|
+
try {
|
|
346
|
+
form = await parseForm(request, body);
|
|
347
|
+
} catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const posted = readPostedAction(form);
|
|
351
|
+
if (!posted) return null;
|
|
352
|
+
if (!isSameOriginRequest(request, allowedOrigins)) return text(403, "Forbidden");
|
|
353
|
+
if (!ID_PATTERN.test(posted.id) || posted.token !== void 0 && !TOKEN_PATTERN.test(posted.token)) return text(400, "Bad Request");
|
|
354
|
+
const actionKey = form.get(ACTION_KEY_FIELD);
|
|
355
|
+
stripRuntimeFields(form);
|
|
356
|
+
const page = new URL(request.url);
|
|
357
|
+
const back = page.pathname + page.search;
|
|
358
|
+
const resolved = await resolveAction(posted.id, posted.token);
|
|
359
|
+
if (!resolved.ok) return resolved.reason === "invalid" ? text(400, "Bad Request") : seeOther(back);
|
|
360
|
+
const ran = await execute(resolved, posted.id, [form]);
|
|
361
|
+
if ("error" in ran) {
|
|
362
|
+
if (ran.outcome?.redirect) return seeOther(ran.outcome.redirect);
|
|
363
|
+
if (ran.outcome?.status) return text(ran.outcome.status, "Server action failed");
|
|
364
|
+
return text(500, "Server action failed");
|
|
365
|
+
}
|
|
366
|
+
const result = ran.result;
|
|
367
|
+
if (typeof actionKey === "string" && options.renderFormState) {
|
|
368
|
+
const boundCount = Math.max(resolved.boundCount - 1, 0);
|
|
369
|
+
return options.renderFormState([
|
|
370
|
+
result,
|
|
371
|
+
actionKey,
|
|
372
|
+
posted.id,
|
|
373
|
+
boundCount
|
|
374
|
+
], request);
|
|
375
|
+
}
|
|
376
|
+
return seeOther(back);
|
|
377
|
+
}
|
|
378
|
+
export { SealError, VALUE_MIN_LENGTH, addressValues, bindInlineAction, configureActionKeys, createActionKeys, decodeActionReference, defineServerHooks, encodeActionReference, escapeScriptJson, generateActionKey, getActionReference, getBuildDefaults, getServerHooks, handleAction, hashValue, isSameOriginRequest, matchesAllowedOrigin, normalizeAllowedOrigin, parseHeldValues, registerInlineAction, registerServerReference, resolveAction, sealBound, selectValuesToSend, setBuildDefaults, setServerHooks, unsealBound };
|
package/dist/vite.d.mts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { r as ClientHooks } from "./_chunks/hooks-De0gNpOn.mjs";
|
|
2
|
+
import { n as AstNode } from "./_chunks/actions-BCtIGvrP.mjs";
|
|
3
|
+
import { n as ServerHooks } from "./_chunks/hooks-3VZV8f_d.mjs";
|
|
4
|
+
import { Plugin } from "vite";
|
|
5
|
+
|
|
6
|
+
// src/vite.d.ts
|
|
7
|
+
interface IslxOptions {
|
|
8
|
+
clientComponents?: {
|
|
9
|
+
/**
|
|
10
|
+
* Client modules to build for the browser even when no page the server build saw imports
|
|
11
|
+
* them, as components chosen by name at runtime. Globs, from the root.
|
|
12
|
+
*/
|
|
13
|
+
include?: string[];
|
|
14
|
+
/** Files never treated as client components, even with `"use client"`. Globs, from the root. */
|
|
15
|
+
exclude?: string[];
|
|
16
|
+
};
|
|
17
|
+
/** The environments that render on the server. Default: every environment but `client`. */
|
|
18
|
+
serverEnvironments?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* The directives your code marks client modules and actions with. Default `"use client"` and
|
|
21
|
+
* `"use server"`; set others when another tool in the app already owns those. Libraries in
|
|
22
|
+
* `node_modules` always use `"use client"` (and their actions are never registered).
|
|
23
|
+
*/
|
|
24
|
+
directives?: {
|
|
25
|
+
client?: string;
|
|
26
|
+
server?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Same-site links move between pages without loading a new document: the runtime, its code
|
|
30
|
+
* and the values the browser holds stay. Default true; false loads every page.
|
|
31
|
+
*/
|
|
32
|
+
navigation?: boolean | {
|
|
33
|
+
viewTransition?: boolean;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Lifecycle hooks: callbacks, or the path of a module that exports them (`defineClientHooks`,
|
|
37
|
+
* `defineServerHooks`). Callbacks are copied into the browser or server build as source, so
|
|
38
|
+
* they may use only their arguments and globals (`window`, `fetch`, `console`, …); hooks that
|
|
39
|
+
* import app code go in a module.
|
|
40
|
+
*/
|
|
41
|
+
hooks?: {
|
|
42
|
+
client?: string | ClientHooks;
|
|
43
|
+
server?: string | ServerHooks;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The source of a hooks module made from callbacks. Each callback is copied as written, so it
|
|
48
|
+
* must not use anything from where it was written: an outside variable, an import.
|
|
49
|
+
*/
|
|
50
|
+
declare function serializeHooks(hooks: Record<string, unknown>, where: "client" | "server", parse: (file: string, code: string) => AstNode): string;
|
|
51
|
+
/** The directives to use, refusing ones that would make a file mean two things. */
|
|
52
|
+
declare function resolveDirectives(directives?: IslxOptions["directives"]): {
|
|
53
|
+
client: string;
|
|
54
|
+
server: string;
|
|
55
|
+
};
|
|
56
|
+
declare function islx(options?: IslxOptions): Plugin[];
|
|
57
|
+
export { IslxOptions, islx as default, islx, resolveDirectives, serializeHooks };
|