@mosetta/server-action 0.0.0-stage → 0.0.9
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/LICENSE +21 -0
- package/README.md +46 -2
- package/package.json +29 -3
- package/src/client/client.ts +61 -0
- package/src/client/executor.ts +101 -0
- package/src/client/manager.ts +224 -0
- package/src/client/serverData.ts +82 -0
- package/src/client/ssrRecorder.ts +80 -0
- package/src/client/stableStringify.ts +11 -0
- package/src/core/core.ts +234 -0
- package/src/server/server.ts +236 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mosetta contributors
|
|
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,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mosetta/server-action
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server actions of the mosetta protocol: server code that ships with a tile and is called from its
|
|
4
|
+
component side.
|
|
5
|
+
|
|
6
|
+
A tile's server side declares actions. Every action says who may call it (`authorize`), what it
|
|
7
|
+
takes (a zod schema) and what it does; the host vouches for the caller, the action decides:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// tiles/greeting/greeting.server.ts
|
|
11
|
+
import { ServerActionToken } from '@mosetta/server-action/core';
|
|
12
|
+
import { DiServerAction, defineServerSide, ServerAction } from '@mosetta/server-action/server';
|
|
13
|
+
import type { ICreateSchema, IServerActionPayload } from '@mosetta/server-action/server';
|
|
14
|
+
|
|
15
|
+
export const greetToken = new ServerActionToken<{ q: string }, { answer: string }>({ actionName: 'greet' });
|
|
16
|
+
|
|
17
|
+
@DiServerAction(greetToken)
|
|
18
|
+
class GreetAction extends ServerAction<{ q: string }, { answer: string }> {
|
|
19
|
+
authorize() {
|
|
20
|
+
return true; // anyone may ask
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
action({ input }: IServerActionPayload<{ q: string }>) {
|
|
24
|
+
return Promise.resolve({ answer: `hello ${input.q}` });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
createSchema({ builder }: ICreateSchema) {
|
|
28
|
+
return builder.object({ q: builder.string() });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default defineServerSide({ services: [GreetAction] });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The component side calls it with `useServerAction` from `@mosetta/react/server-action`; during
|
|
36
|
+
server rendering the same call runs on the server, so the page arrives with the data in it.
|
|
37
|
+
|
|
38
|
+
## Entry points
|
|
39
|
+
|
|
40
|
+
| Entry | What it holds |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `@mosetta/server-action/server` | `defineServerSide`, `ServerAction`, `DiServerAction`, `DiInject`; what an action may ask for: the caller, the request, the project's secrets by name (`SECRETS_TOKEN`), its stores (`PROVIDERS_TOKEN`), the site's tile hosts (`TILE_HOSTS_TOKEN`) |
|
|
43
|
+
| `@mosetta/server-action/client` | the browser's call of an action |
|
|
44
|
+
| `@mosetta/server-action/core` | `ServerActionToken` (shared by the server and the component side), serialization, the caller as the host vouches for it |
|
|
45
|
+
|
|
46
|
+
An action runs in an isolated line of the factory: its privileges are handed in, never assumed —
|
|
47
|
+
no store key and no secret value sits in its environment.
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mosetta/server-action",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.0.9",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "mosetta server actions (RPC): shared token/serializer contract, client hook, server-side action definition. Web-API native (workerd).",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
"./core": "./src/core/core.ts",
|
|
12
|
+
"./client": "./src/client/client.ts",
|
|
13
|
+
"./server": "./src/server/server.ts"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"react": "*",
|
|
17
|
+
"zod": "^3",
|
|
18
|
+
"@mosetta/di": "0.0.9",
|
|
19
|
+
"@mosetta/runtime": "0.0.9",
|
|
20
|
+
"@mosetta/stores": "0.0.9",
|
|
21
|
+
"@mosetta/router": "0.0.9"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/react": "^19"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
}
|
|
6
32
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @mosetta/server-action / client — the client slice: the ServerData model, the
|
|
2
|
+
// tag-invalidated query cache and the fetch transport. The react hook (useServerAction) lives in
|
|
3
|
+
// the binding — @mosetta/react/server-action.
|
|
4
|
+
import { DiContainer } from '@mosetta/di';
|
|
5
|
+
import { SSR_RENDER_CHANNEL_TOKEN } from '@mosetta/runtime/client';
|
|
6
|
+
import type { ISsrRenderChannel } from '@mosetta/runtime/client';
|
|
7
|
+
import { CLIENT_SERIALIZER_TOKEN, EXECUTION_ENV_TOKEN, JsonClientSerializer, SERVER_ACTION_EXECUTOR_TOKEN } from '../core/core';
|
|
8
|
+
import { FetchServerActionExecutor, OriginFetchServerActionExecutor } from './executor';
|
|
9
|
+
import { ServerDataManager } from './manager';
|
|
10
|
+
import { ACTION_CACHE_ELEMENT_ID, seedManagerFromDocument, SsrActionRecorder, SSR_ACTION_RECORDER_TOKEN } from './ssrRecorder';
|
|
11
|
+
import { SERVER_DATA_MANAGER } from './manager';
|
|
12
|
+
|
|
13
|
+
export * from './serverData';
|
|
14
|
+
export * from './stableStringify';
|
|
15
|
+
export { SERVER_DATA_MANAGER, ServerDataManager } from './manager';
|
|
16
|
+
export type { IServerDataHandle } from './manager';
|
|
17
|
+
export { FetchServerActionExecutor, OriginFetchServerActionExecutor } from './executor';
|
|
18
|
+
export * from './ssrRecorder';
|
|
19
|
+
// the pieces a consumer typically re-exports from core
|
|
20
|
+
export { EServerActionStart, EXECUTION_ENV_TOKEN } from '../core/core';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wire the client slice into an app's container. The app calls this from its Component
|
|
24
|
+
* initializer with the `ssrChannel` prop: absent → the browser wiring; present →
|
|
25
|
+
* the SERVER wiring — same manager/cache, but the executor targets the runner origin absolutely,
|
|
26
|
+
* the environment flips to 'server' and the recorder drives the head channel's counter.
|
|
27
|
+
*/
|
|
28
|
+
export function registerServerActionClient(di: DiContainer, ssrChannel?: ISsrRenderChannel): void {
|
|
29
|
+
const serializer = new JsonClientSerializer();
|
|
30
|
+
di.registerValueOfToken(CLIENT_SERIALIZER_TOKEN, serializer);
|
|
31
|
+
di.registerServices(ServerDataManager);
|
|
32
|
+
if (ssrChannel) {
|
|
33
|
+
di.registerValueOfToken(EXECUTION_ENV_TOKEN, 'server');
|
|
34
|
+
di.registerValueOfToken(SERVER_ACTION_EXECUTOR_TOKEN,
|
|
35
|
+
new OriginFetchServerActionExecutor(serializer, ssrChannel.requestOrigin ?? ''));
|
|
36
|
+
di.registerValueOfToken(SSR_ACTION_RECORDER_TOKEN, new SsrActionRecorder(ssrChannel));
|
|
37
|
+
} else {
|
|
38
|
+
di.registerServices(FetchServerActionExecutor);
|
|
39
|
+
di.registerValueOfToken(EXECUTION_ENV_TOKEN, 'browser');
|
|
40
|
+
di.registerValueOfToken(SSR_ACTION_RECORDER_TOKEN, null);
|
|
41
|
+
// hydration seeds (stage 4): server-computed results from the head cache script — parked in
|
|
42
|
+
// the manager, consumed by each query's first ensureQuery (mirror parity, no refetch).
|
|
43
|
+
// Instantiated eagerly ONLY when the page actually carries a cache.
|
|
44
|
+
if (typeof document !== 'undefined' && document.getElementById(ACTION_CACHE_ELEMENT_ID)) {
|
|
45
|
+
seedManagerFromDocument(di.getByToken(SERVER_DATA_MANAGER));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lazy, idempotent "carry your own" wiring: called by useServerAction on
|
|
52
|
+
* every render — the FIRST call registers the slice into the TOP scope of the inherited
|
|
53
|
+
* per-request container (the page-wide cache/tags must be shared), later calls see the manager
|
|
54
|
+
* and bail. The ssr channel arrives as DATA (SSR_RENDER_CHANNEL_TOKEN, stamped by
|
|
55
|
+
* createAppContainer) — the root module knows nothing about server actions anymore.
|
|
56
|
+
*/
|
|
57
|
+
export function ensureServerActionClient(di: DiContainer): void {
|
|
58
|
+
if (di.hasByToken(SERVER_DATA_MANAGER)) return;
|
|
59
|
+
const ssrChannel = di.hasByToken(SSR_RENDER_CHANNEL_TOKEN) ? di.getByToken(SSR_RENDER_CHANNEL_TOKEN) : null;
|
|
60
|
+
registerServerActionClient(di, ssrChannel ?? undefined);
|
|
61
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The browser transport: POST to the canonical origin-absolute route. Never rejects —
|
|
2
|
+
// every failure (HTTP or network) is folded into IServerActionResult.error so the ServerData model is
|
|
3
|
+
// the single error channel. The ONE exception is an abort of options.signal: that re-throws, because
|
|
4
|
+
// it is not a server outcome — the manager initiated it and discards the whole try.
|
|
5
|
+
import { DiInject, DiService } from '@mosetta/di';
|
|
6
|
+
import {
|
|
7
|
+
CLIENT_SERIALIZER_TOKEN,
|
|
8
|
+
ClientSerializer,
|
|
9
|
+
IExecuteServerActionOptions,
|
|
10
|
+
IServerActionError,
|
|
11
|
+
IServerActionResult,
|
|
12
|
+
SERVER_ACTION_EXECUTOR_TOKEN,
|
|
13
|
+
serverActionRoute,
|
|
14
|
+
ServerActionExecutor,
|
|
15
|
+
} from '../core/core';
|
|
16
|
+
|
|
17
|
+
@DiService(SERVER_ACTION_EXECUTOR_TOKEN)
|
|
18
|
+
export class FetchServerActionExecutor extends ServerActionExecutor {
|
|
19
|
+
private readonly fetchImpl: typeof fetch;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
@DiInject(CLIENT_SERIALIZER_TOKEN)
|
|
23
|
+
private readonly defaultSerializer: ClientSerializer,
|
|
24
|
+
) {
|
|
25
|
+
super();
|
|
26
|
+
// bind to globalThis — the global fetch must not be called as a method of `this`
|
|
27
|
+
this.fetchImpl = fetch.bind(globalThis);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// origin-relative in the browser (the page origin IS the runner); the ssr isolate overrides
|
|
31
|
+
protected route(fullId: string, actionName: string): string {
|
|
32
|
+
return serverActionRoute(fullId, actionName);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
executeServerAction = async <TOutput>(
|
|
36
|
+
options: IExecuteServerActionOptions,
|
|
37
|
+
): Promise<IServerActionResult<TOutput>> => {
|
|
38
|
+
const { request, requestHash } = options;
|
|
39
|
+
const serializer = options.customSerializer ?? this.defaultSerializer;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const body = await serializer.serializeIntoRequestBody(request.input as object);
|
|
43
|
+
const response = await this.fetchImpl(this.route(request.fullId, request.actionName), {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
body: body.content,
|
|
46
|
+
headers: body.contentType ? { 'content-type': body.contentType } : {},
|
|
47
|
+
signal: options.signal,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (response.ok) {
|
|
51
|
+
const output = (await serializer.parseFromResponse(response)) as TOutput;
|
|
52
|
+
return { requestHash, output };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// non-2xx: the glue's wire format is { error: { code, ... } }; anything else → a synthetic code
|
|
56
|
+
// carrying a snippet of the RAW body — a proxy's HTML page or a plain-text crash is the actual
|
|
57
|
+
// clue, and statusText alone is useless (HTTP/2 dropped reason phrases entirely)
|
|
58
|
+
let error: IServerActionError | undefined;
|
|
59
|
+
let raw = '';
|
|
60
|
+
try {
|
|
61
|
+
raw = await response.text();
|
|
62
|
+
error = ((await serializer.parseFromString({
|
|
63
|
+
content: raw,
|
|
64
|
+
contentType: response.headers.get('content-type') ?? '',
|
|
65
|
+
serializerName: serializer.serializerName,
|
|
66
|
+
})) as { error?: IServerActionError }).error;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// aborted mid-body-read is not an outcome either — let the outer catch rethrow it;
|
|
69
|
+
// an unparseable body IS surfaced: via the raw snippet below
|
|
70
|
+
if (options.signal?.aborted) throw e;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
requestHash,
|
|
74
|
+
error: error ?? {
|
|
75
|
+
code: `SERVER_ACTION_HTTP_${response.status}`,
|
|
76
|
+
message: raw.trim().slice(0, 300) || response.statusText,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// aborted mid-fetch OR mid-body-read: not a network outcome — propagate to the initiator
|
|
81
|
+
if (options.signal?.aborted) throw e;
|
|
82
|
+
return {
|
|
83
|
+
requestHash,
|
|
84
|
+
error: { code: 'SERVER_ACTION_NETWORK', message: String((e as Error)?.message ?? e) },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The ssr-isolate twin: SAME endpoint, but absolute — a worker has no page origin
|
|
91
|
+
// to resolve a relative route against. `origin` is the runner origin of the original request
|
|
92
|
+
// (rides the render channel). Constructed manually (not DI-scanned) by registerServerActionClient.
|
|
93
|
+
export class OriginFetchServerActionExecutor extends FetchServerActionExecutor {
|
|
94
|
+
constructor(serializer: ClientSerializer, private readonly origin: string) {
|
|
95
|
+
super(serializer);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
protected override route(fullId: string, actionName: string): string {
|
|
99
|
+
return new URL(serverActionRoute(fullId, actionName), this.origin).toString();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// ServerDataManager v2: the query cache + tag invalidation + manual (mutation) handles.
|
|
2
|
+
// Subscriptions live on ENTRIES (persistent, with unsubscribe — the useSyncExternalStore contract),
|
|
3
|
+
// snapshots are immutable ServerData instances replaced only on transitions. Invalidation of a tag
|
|
4
|
+
// refetches ACTIVE entries immediately (stale-while-refetch: the previous Success stays visible until
|
|
5
|
+
// the fresh result lands) and lazily marks inactive ones (refetch on next mount — entries are kept so
|
|
6
|
+
// a subscribe can never race a deletion).
|
|
7
|
+
//
|
|
8
|
+
// Cancellation model: each run of a query owns a GENERATION + an AbortController.
|
|
9
|
+
// The generation is the source of truth about whose landing counts — a superseded run's response is
|
|
10
|
+
// discarded even if it slips past the abort (abort and landing can race within one tick). The abort
|
|
11
|
+
// is network hygiene on top: superseded/abandoned requests actually disappear from the wire (devtools
|
|
12
|
+
// shows them as cancelled, not as zombie in-flights). Mutations are NEVER aborted: by the time anyone
|
|
13
|
+
// could cancel, the write is already on the server, and skipping the .then would lose `invalidate`.
|
|
14
|
+
import { DiInject, DiService, SingleDiToken } from '@mosetta/di';
|
|
15
|
+
import {
|
|
16
|
+
ClientSerializer,
|
|
17
|
+
IServerActionRequest,
|
|
18
|
+
SERVER_ACTION_EXECUTOR_TOKEN,
|
|
19
|
+
ServerActionExecutor,
|
|
20
|
+
} from '../core/core';
|
|
21
|
+
import {
|
|
22
|
+
IdleServerData,
|
|
23
|
+
IManualStart,
|
|
24
|
+
LoadingServerData,
|
|
25
|
+
ServerData,
|
|
26
|
+
serverDataOfResult,
|
|
27
|
+
} from './serverData';
|
|
28
|
+
|
|
29
|
+
export interface IEnsureQueryOptions {
|
|
30
|
+
readonly key: string;
|
|
31
|
+
readonly tags: readonly string[];
|
|
32
|
+
readonly request: IServerActionRequest;
|
|
33
|
+
readonly customSerializer?: ClientSerializer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ICreateManualOptions {
|
|
37
|
+
readonly fullId: string;
|
|
38
|
+
readonly actionName: string;
|
|
39
|
+
readonly invalidates: readonly string[];
|
|
40
|
+
readonly customSerializer?: ClientSerializer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** the hook-facing subscription surface — same shape for query entries and manual handles */
|
|
44
|
+
export interface IServerDataHandle<TOut, TIn = unknown> {
|
|
45
|
+
subscribe(listener: () => void): () => void;
|
|
46
|
+
snapshot(): ServerData<TOut, TIn>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface IQueryEntry {
|
|
50
|
+
readonly tags: readonly string[];
|
|
51
|
+
readonly listeners: Set<() => void>;
|
|
52
|
+
current: ServerData<unknown>;
|
|
53
|
+
inflight: Promise<void> | null;
|
|
54
|
+
/** controller of the CURRENT generation's request; null once it lands */
|
|
55
|
+
controller: AbortController | null;
|
|
56
|
+
/** bumped by every run (and by last-unsubscribe abort) — an older run's landing is discarded */
|
|
57
|
+
generation: number;
|
|
58
|
+
invalidated: boolean;
|
|
59
|
+
run(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const SERVER_DATA_MANAGER = new SingleDiToken<ServerDataManager>('SERVER_DATA_MANAGER');
|
|
63
|
+
|
|
64
|
+
@DiService(SERVER_DATA_MANAGER)
|
|
65
|
+
export class ServerDataManager {
|
|
66
|
+
private readonly entries = new Map<string, IQueryEntry>();
|
|
67
|
+
|
|
68
|
+
// hydration seeds: server-computed results parked until the OWNING hook mounts —
|
|
69
|
+
// ensureQuery consumes a seed instead of fetching, so the entry keeps the hook's tags and the
|
|
70
|
+
// first client render shows the SAME data the server rendered (no Loading flash, no refetch)
|
|
71
|
+
private readonly seeds = new Map<string, ServerData<unknown>>();
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
@DiInject(SERVER_ACTION_EXECUTOR_TOKEN)
|
|
75
|
+
private readonly executor: ServerActionExecutor,
|
|
76
|
+
) {}
|
|
77
|
+
|
|
78
|
+
/** park a server-computed snapshot for the key; consumed by the first ensureQuery */
|
|
79
|
+
seedQuery(key: string, data: ServerData<unknown>): void {
|
|
80
|
+
if (!this.entries.has(key)) this.seeds.set(key, data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Idempotent by key; called during render (the standard start-on-first-read pattern — safe because
|
|
85
|
+
* repeated calls are no-ops). A previously invalidated inactive entry refetches here, keeping its
|
|
86
|
+
* stale snapshot until the fresh result arrives.
|
|
87
|
+
*/
|
|
88
|
+
ensureQuery({ key, tags, request, customSerializer }: IEnsureQueryOptions): void {
|
|
89
|
+
const existing = this.entries.get(key);
|
|
90
|
+
if (existing) {
|
|
91
|
+
if (existing.invalidated && !existing.inflight) existing.run();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const entry: IQueryEntry = {
|
|
96
|
+
tags,
|
|
97
|
+
listeners: new Set(),
|
|
98
|
+
current: undefined as unknown as ServerData<unknown>,
|
|
99
|
+
inflight: null,
|
|
100
|
+
controller: null,
|
|
101
|
+
generation: 0,
|
|
102
|
+
invalidated: false,
|
|
103
|
+
run: () => {
|
|
104
|
+
// supersede the previous try: cancel it on the wire AND orphan its landing by the bump
|
|
105
|
+
// (the two are separate on purpose — a response can land in the same tick the abort fires)
|
|
106
|
+
entry.controller?.abort();
|
|
107
|
+
const gen = ++entry.generation;
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
entry.controller = controller;
|
|
110
|
+
entry.invalidated = false;
|
|
111
|
+
const promise = this.executor
|
|
112
|
+
.executeServerAction({ request, requestHash: key, customSerializer, signal: controller.signal })
|
|
113
|
+
.then((result) => {
|
|
114
|
+
if (gen !== entry.generation) return; // orphaned: a newer generation owns this entry
|
|
115
|
+
entry.inflight = null;
|
|
116
|
+
entry.controller = null;
|
|
117
|
+
entry.current = serverDataOfResult(result);
|
|
118
|
+
// a tag invalidation landed mid-flight while nobody could refetch (entry was inactive) —
|
|
119
|
+
// this result is already suspect: show it as the stale and immediately go again
|
|
120
|
+
if (entry.invalidated && entry.listeners.size > 0) entry.run();
|
|
121
|
+
for (const listener of entry.listeners) listener();
|
|
122
|
+
})
|
|
123
|
+
.catch(() => {
|
|
124
|
+
// abort — always initiated by this manager (supersession or last-unsubscribe); swallowed
|
|
125
|
+
// so the waiter promise NEVER rejects (opt-in suspense throws it in both runtimes)
|
|
126
|
+
});
|
|
127
|
+
entry.inflight = promise;
|
|
128
|
+
// no data yet → (re)issue Loading with the CURRENT waiter, so a suspense retry after an
|
|
129
|
+
// aborted try suspends on a live promise instead of spinning on a settled one; a stale
|
|
130
|
+
// Success/Error stays visible instead (stale-while-refetch)
|
|
131
|
+
if (!entry.current || entry.current.isLoading()) {
|
|
132
|
+
entry.current = new LoadingServerData<unknown>(promise);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
this.entries.set(key, entry);
|
|
137
|
+
const seed = this.seeds.get(key);
|
|
138
|
+
if (seed) {
|
|
139
|
+
// hydration parity (v1: seed without revalidation-on-mount) — a later tag invalidation
|
|
140
|
+
// refetches through the normal run() path
|
|
141
|
+
this.seeds.delete(key);
|
|
142
|
+
entry.current = seed;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
entry.run();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
subscribe(key: string, listener: () => void): () => void {
|
|
149
|
+
const entry = this.entries.get(key);
|
|
150
|
+
if (!entry) return () => {};
|
|
151
|
+
entry.listeners.add(listener);
|
|
152
|
+
// revival: the entry was cancelled/invalidated while nobody watched (e.g. its fetch was aborted
|
|
153
|
+
// between this hook's render and its effect-phase subscription)
|
|
154
|
+
if (entry.invalidated && !entry.inflight) entry.run();
|
|
155
|
+
return () => {
|
|
156
|
+
entry.listeners.delete(listener);
|
|
157
|
+
// last watcher gone with a request still in flight → nobody wants the answer: cancel on the
|
|
158
|
+
// wire, orphan the landing, refetch on the next mount. Covers unmount AND a hook switching
|
|
159
|
+
// cache keys (debounced-search style). The entry itself is kept — subscribe never races a
|
|
160
|
+
// deletion — and revives via ensureQuery/subscribe.
|
|
161
|
+
if (entry.listeners.size === 0 && entry.inflight) {
|
|
162
|
+
entry.generation += 1;
|
|
163
|
+
entry.controller?.abort();
|
|
164
|
+
entry.controller = null;
|
|
165
|
+
entry.inflight = null;
|
|
166
|
+
entry.invalidated = true;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
snapshot<TOut>(key: string): ServerData<TOut> {
|
|
172
|
+
const entry = this.entries.get(key);
|
|
173
|
+
if (!entry) throw new Error(`server-action query ${key} was not ensured before read`);
|
|
174
|
+
return entry.current as ServerData<TOut>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** tag invalidation: active entries refetch now, inactive refetch on next mount */
|
|
178
|
+
invalidate(tags: readonly string[]): void {
|
|
179
|
+
if (!tags.length) return;
|
|
180
|
+
const hit = new Set(tags);
|
|
181
|
+
for (const entry of this.entries.values()) {
|
|
182
|
+
if (!entry.tags.some((t) => hit.has(t))) continue;
|
|
183
|
+
if (entry.listeners.size > 0) {
|
|
184
|
+
// unconditionally: run() itself cancels+orphans an in-flight PRE-invalidation try — its
|
|
185
|
+
// response was computed before the write and must never be presented as fresh
|
|
186
|
+
entry.run();
|
|
187
|
+
} else {
|
|
188
|
+
entry.invalidated = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** a per-hook mutation handle (START_MANUAL): never cached, never aborted (the write is already
|
|
194
|
+
* on the server; killing the .then would silently lose `invalidate`), input arrives at start(input) */
|
|
195
|
+
createManual<TIn, TOut>({ fullId, actionName, invalidates, customSerializer }: ICreateManualOptions): IServerDataHandle<TOut, TIn> {
|
|
196
|
+
const listeners = new Set<() => void>();
|
|
197
|
+
const notify = () => {
|
|
198
|
+
for (const listener of listeners) listener();
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
let current: ServerData<TOut, TIn>;
|
|
202
|
+
const start: IManualStart<TIn> = (input) => {
|
|
203
|
+
const request: IServerActionRequest = { fullId, actionName, input };
|
|
204
|
+
const promise = this.executor
|
|
205
|
+
.executeServerAction<TOut>({ request, requestHash: `${fullId}/${actionName}#manual`, customSerializer })
|
|
206
|
+
.then((result) => {
|
|
207
|
+
current = serverDataOfResult<TOut, TIn>(result, start);
|
|
208
|
+
if (!result.error) this.invalidate(invalidates);
|
|
209
|
+
notify();
|
|
210
|
+
});
|
|
211
|
+
current = new LoadingServerData<TOut, TIn>(promise, start);
|
|
212
|
+
notify();
|
|
213
|
+
};
|
|
214
|
+
current = new IdleServerData<TOut, TIn>(start);
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
subscribe: (listener) => {
|
|
218
|
+
listeners.add(listener);
|
|
219
|
+
return () => listeners.delete(listener);
|
|
220
|
+
},
|
|
221
|
+
snapshot: () => current,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ServerData v2: IMMUTABLE state snapshots of a server-action call. Sanitized against
|
|
2
|
+
// the legacy model's traps: no subscribers on the snapshot (subscriptions live on the manager/handle
|
|
3
|
+
// entry), no single-shot auto-clearing, void output IS a success, and no module-level shared instances.
|
|
4
|
+
import { IServerActionError, IServerActionResult } from '../core/core';
|
|
5
|
+
|
|
6
|
+
// Manual-mode (START_MANUAL) trigger: present on EVERY state of a mutation hook so the editor can
|
|
7
|
+
// re-run it from any point (Idle → run, Success/Error → run again). Queries never carry it.
|
|
8
|
+
export interface IManualStart<TIn> {
|
|
9
|
+
(input: TIn): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export abstract class ServerData<TOut, TIn = unknown> {
|
|
13
|
+
/** mutation trigger (manual mode only); undefined on query states */
|
|
14
|
+
readonly start?: IManualStart<TIn>;
|
|
15
|
+
|
|
16
|
+
protected constructor(start?: IManualStart<TIn>) {
|
|
17
|
+
this.start = start;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
isIdle(): this is IdleServerData<TOut, TIn> {
|
|
21
|
+
// eslint-disable-next-line no-use-before-define
|
|
22
|
+
return this instanceof IdleServerData;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
isLoading(): this is LoadingServerData<TOut, TIn> {
|
|
26
|
+
// eslint-disable-next-line no-use-before-define
|
|
27
|
+
return this instanceof LoadingServerData;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
isSuccess(): this is SuccessServerData<TOut, TIn> {
|
|
31
|
+
// eslint-disable-next-line no-use-before-define
|
|
32
|
+
return this instanceof SuccessServerData;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isError(): this is ErrorServerData<TOut, TIn> {
|
|
36
|
+
// eslint-disable-next-line no-use-before-define
|
|
37
|
+
return this instanceof ErrorServerData;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class IdleServerData<TOut, TIn = unknown> extends ServerData<TOut, TIn> {
|
|
42
|
+
constructor(start: IManualStart<TIn>) {
|
|
43
|
+
super(start);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class LoadingServerData<TOut, TIn = unknown> extends ServerData<TOut, TIn> {
|
|
48
|
+
/** the in-flight promise — thrown by the hook when opt-in suspense is on */
|
|
49
|
+
readonly waiter: Promise<void>;
|
|
50
|
+
|
|
51
|
+
constructor(waiter: Promise<void>, start?: IManualStart<TIn>) {
|
|
52
|
+
super(start);
|
|
53
|
+
this.waiter = waiter;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class SuccessServerData<TOut, TIn = unknown> extends ServerData<TOut, TIn> {
|
|
58
|
+
readonly serverData: TOut;
|
|
59
|
+
|
|
60
|
+
constructor(output: TOut, start?: IManualStart<TIn>) {
|
|
61
|
+
super(start);
|
|
62
|
+
this.serverData = output;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class ErrorServerData<TOut, TIn = unknown> extends ServerData<TOut, TIn> {
|
|
67
|
+
readonly serverError: IServerActionError;
|
|
68
|
+
|
|
69
|
+
constructor(error: IServerActionError, start?: IManualStart<TIn>) {
|
|
70
|
+
super(start);
|
|
71
|
+
this.serverError = error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** result → snapshot; ABSENT error = success even for void/undefined output (legacy trap №3) */
|
|
76
|
+
export function serverDataOfResult<TOut, TIn>(
|
|
77
|
+
result: IServerActionResult<TOut>,
|
|
78
|
+
start?: IManualStart<TIn>,
|
|
79
|
+
): ServerData<TOut, TIn> {
|
|
80
|
+
if (result.error) return new ErrorServerData<TOut, TIn>(result.error, start);
|
|
81
|
+
return new SuccessServerData<TOut, TIn>(result.output as TOut, start);
|
|
82
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SsrActionRecorder: the per-request bookkeeper of server-executed actions.
|
|
2
|
+
// It drives the head channel's pending counter BY THE CHANNEL'S RULES — begin on the render that
|
|
3
|
+
// starts a query, complete on the RETRY render that consumed the settled data (both idempotent
|
|
4
|
+
// per KEY, so deduped hooks over one cache entry count once) — and accumulates the results into
|
|
5
|
+
// ONE head entry: the action-cache script the client reads before hydration (stage 4).
|
|
6
|
+
import { SingleDiToken } from '@mosetta/di';
|
|
7
|
+
import { ACTION_CACHE_HEAD_KEY, ISsrRenderChannel } from '@mosetta/runtime/client';
|
|
8
|
+
import { IServerActionError } from '../core/core';
|
|
9
|
+
import type { ServerDataManager } from './manager';
|
|
10
|
+
import { ErrorServerData, ServerData, SuccessServerData } from './serverData';
|
|
11
|
+
|
|
12
|
+
// the collector key is the WELL-KNOWN reserved name from @mosetta/runtime/client — module writes
|
|
13
|
+
// under it are ignored by useAddToHead, only this recorder ships the cache
|
|
14
|
+
export { ACTION_CACHE_HEAD_KEY } from '@mosetta/runtime/client';
|
|
15
|
+
/** DOM id of the cache script (one entry, rewritten as results accumulate) */
|
|
16
|
+
export const ACTION_CACHE_ELEMENT_ID = 'mosettaActionCache';
|
|
17
|
+
|
|
18
|
+
/** cache payload: query key → settled result (errors ride too — first-render parity, stage 4) */
|
|
19
|
+
export type TActionCacheEntry = { output?: unknown; error?: IServerActionError };
|
|
20
|
+
|
|
21
|
+
// null in the browser and in channel-less server renders (the v1 Loading-stub degenerate)
|
|
22
|
+
export const SSR_ACTION_RECORDER_TOKEN = new SingleDiToken<SsrActionRecorder | null>('SSR_ACTION_RECORDER_TOKEN');
|
|
23
|
+
|
|
24
|
+
// The browser half of the cache (stage 4): parse the script the server shipped and park every
|
|
25
|
+
// result as a hydration seed — the first client render then shows the SAME data the server
|
|
26
|
+
// rendered (mirror parity: no Loading flash, no refetch, no hydration mismatch). Errors seed
|
|
27
|
+
// too — a server-rendered Error state must hydrate as itself.
|
|
28
|
+
export function seedManagerFromDocument(manager: ServerDataManager): void {
|
|
29
|
+
if (typeof document === 'undefined') return;
|
|
30
|
+
const script = document.getElementById(ACTION_CACHE_ELEMENT_ID);
|
|
31
|
+
if (!script?.textContent) return;
|
|
32
|
+
let parsed: Record<string, TActionCacheEntry>;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(script.textContent) as Record<string, TActionCacheEntry>;
|
|
35
|
+
} catch {
|
|
36
|
+
return; // a mangled cache is a missing cache — queries just fetch
|
|
37
|
+
}
|
|
38
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
39
|
+
manager.seedQuery(key, entry.error
|
|
40
|
+
? new ErrorServerData<unknown>(entry.error)
|
|
41
|
+
: new SuccessServerData<unknown>(entry.output));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class SsrActionRecorder {
|
|
46
|
+
private readonly begun = new Set<string>();
|
|
47
|
+
|
|
48
|
+
private readonly settled = new Set<string>();
|
|
49
|
+
|
|
50
|
+
private readonly results: Record<string, TActionCacheEntry> = {};
|
|
51
|
+
|
|
52
|
+
constructor(private readonly channel: ISsrRenderChannel) {}
|
|
53
|
+
|
|
54
|
+
/** idempotent per key — called on the render that ensured the query */
|
|
55
|
+
onQueryStart(key: string): void {
|
|
56
|
+
if (this.begun.has(key)) return;
|
|
57
|
+
this.begun.add(key);
|
|
58
|
+
this.channel.beginAction();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** idempotent per key — called on the RETRY render that sees Success/Error (never from .then) */
|
|
62
|
+
// TIn=never: the start-callback position is contravariant, never accepts every ServerData
|
|
63
|
+
onQuerySettled(key: string, data: ServerData<unknown, never>): void {
|
|
64
|
+
if (!this.begun.has(key) || this.settled.has(key)) return;
|
|
65
|
+
this.settled.add(key);
|
|
66
|
+
if (data.isSuccess()) this.results[key] = { output: data.serverData };
|
|
67
|
+
else if (data.isError()) this.results[key] = { error: data.serverError };
|
|
68
|
+
try {
|
|
69
|
+
// one keyed entry, last write wins — each settle re-ships the WHOLE accumulated map
|
|
70
|
+
const json = JSON.stringify(this.results).replace(/</g, '\\u003c');
|
|
71
|
+
this.channel.addToHead(ACTION_CACHE_HEAD_KEY, 'script',
|
|
72
|
+
{ type: 'application/json', id: ACTION_CACHE_ELEMENT_ID }, json);
|
|
73
|
+
} catch {
|
|
74
|
+
// unserializable output (custom serializer territory) — drop it from the cache; the page
|
|
75
|
+
// still renders, the client just refetches this one
|
|
76
|
+
delete this.results[key];
|
|
77
|
+
}
|
|
78
|
+
this.channel.completeAction();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Deterministic serialization for cache keys: recursive key sort, so semantically equal
|
|
2
|
+
// inputs always produce the same key. Replaces the legacy @mosetta/object-hash dependency.
|
|
3
|
+
export function stableStringify(value: unknown): string {
|
|
4
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'undefined';
|
|
5
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
6
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
7
|
+
.filter(([, v]) => v !== undefined)
|
|
8
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
9
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
10
|
+
return `{${entries.join(',')}}`;
|
|
11
|
+
}
|
package/src/core/core.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { SingleDiToken } from '@mosetta/di';
|
|
2
|
+
|
|
3
|
+
// @mosetta/server-action / core — the shared contract, imported by BOTH the client hook and the server
|
|
4
|
+
// module. Web-API native: serializers speak the platform `Request`/`Response`, never node
|
|
5
|
+
// `http.IncomingMessage` / `stream.Readable`.
|
|
6
|
+
|
|
7
|
+
// ---- execution environment ----
|
|
8
|
+
// Registered by the runtime that owns the tree: the app module in the browser, the (future) SSR renderer
|
|
9
|
+
// in the isolate. A container property, NOT React context — the hook branches on values, never on hooks.
|
|
10
|
+
export type ExecutionEnv = 'browser' | 'server';
|
|
11
|
+
export const EXECUTION_ENV_TOKEN = new SingleDiToken<ExecutionEnv>('EXECUTION_ENV_TOKEN');
|
|
12
|
+
|
|
13
|
+
// ---- who is calling ------------------------------------------------------------------------
|
|
14
|
+
//
|
|
15
|
+
// Who calls is said by the HOST — the trust boundary in front of the runner (a platform's Worker
|
|
16
|
+
// that verified a session, a dev server, a self-hosted gateway) — in one header it strips from every
|
|
17
|
+
// incoming request before setting its own. What it says is ITS words: a platform that signs people
|
|
18
|
+
// in with GitHub and keeps project roles says `{ via, login, roles }`, another host says what its
|
|
19
|
+
// model has. The framework passes them through to the action and never reads them; an action that
|
|
20
|
+
// cares injects CALLER_TOKEN, and every action's `authorize` gets them.
|
|
21
|
+
export const IDENTITY_HEADER = 'x-mosetta-identity';
|
|
22
|
+
|
|
23
|
+
// WHERE a platform's API is asked to publish: the target's name and its policy, set by the host
|
|
24
|
+
// once it checked the caller may publish there (and stripped from every incoming request, like the
|
|
25
|
+
// identity). The API's code reads the target from here, never from the body it was sent.
|
|
26
|
+
export const PUBLISH_TARGET_HEADER = 'x-mosetta-publish-target';
|
|
27
|
+
|
|
28
|
+
// The boundary's own CREDENTIAL: the session cookie the platform's Worker signs and reads. Code
|
|
29
|
+
// inside learns WHO through the identity header, never holds the key itself: the runner drops
|
|
30
|
+
// this cookie from every request it hands an isolate, and any Set-Cookie of it from an isolate's
|
|
31
|
+
// answer. Every other cookie is the project's own and passes both ways.
|
|
32
|
+
export const SESSION_COOKIE = 'mosetta-session';
|
|
33
|
+
|
|
34
|
+
/** what the host vouches for about the caller — the host's own words, unread by the framework */
|
|
35
|
+
export interface ICaller {
|
|
36
|
+
readonly [claim: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** the header's JSON object as the caller, or null: absent, malformed, or not an object */
|
|
40
|
+
export function parseCaller(header: string | null | undefined): ICaller | null {
|
|
41
|
+
if (!header) return null;
|
|
42
|
+
try {
|
|
43
|
+
const raw: unknown = JSON.parse(header);
|
|
44
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as ICaller : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const CALLER_TOKEN = new SingleDiToken<ICaller | null>('CALLER_TOKEN');
|
|
51
|
+
|
|
52
|
+
// ---- canonical naming ----
|
|
53
|
+
// MUST agree with cli/runtime/naming.js (the build-side single source of truth for artifact ids) —
|
|
54
|
+
// the CLI runtime is plain dependency-free JS bundled into bootstrap, so the encoder is duplicated
|
|
55
|
+
// here for browser/isolate consumers and PINNED by a cross-check test (unify under the rebrand).
|
|
56
|
+
export const normalizeVersion = (v: string) => String(v).trim().replace(/[^a-zA-Z0-9]/g, '_');
|
|
57
|
+
|
|
58
|
+
/** canonical full-id of a SERVER module: _ns_<ns>_t_server_n_<name>_v_<normalized-version> */
|
|
59
|
+
export function fullServerTileId({ namespace, name, version }: {
|
|
60
|
+
readonly namespace: string;
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly version: string;
|
|
63
|
+
}): string {
|
|
64
|
+
return `_ns_${namespace}_t_server_n_${name}_v_${normalizeVersion(version)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- request / result shapes ----
|
|
68
|
+
export interface IServerActionRequest {
|
|
69
|
+
/** canonical full-id of the target server module (direct file addressing) */
|
|
70
|
+
readonly fullId: string;
|
|
71
|
+
readonly actionName: string;
|
|
72
|
+
readonly input: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The error shape ON THE WIRE — exactly what the isolate glue returns ({ error: { code, ... } });
|
|
76
|
+
// transport-level failures are mapped into the same shape by the client executor.
|
|
77
|
+
export interface IServerActionError {
|
|
78
|
+
readonly code: string;
|
|
79
|
+
readonly message?: string;
|
|
80
|
+
readonly issues?: unknown[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface IServerActionResult<TOutput = unknown> {
|
|
84
|
+
readonly requestHash: string;
|
|
85
|
+
readonly output?: TOutput;
|
|
86
|
+
readonly error?: IServerActionError;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface IContent<T> {
|
|
90
|
+
readonly contentType: string;
|
|
91
|
+
readonly serializerName: string;
|
|
92
|
+
readonly content: T;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- when the action fires (SPA uses START_FROM_CLIENT; SSR pre-compute is deferred) ----
|
|
96
|
+
export enum EServerActionStart {
|
|
97
|
+
START_FROM_SERVER = 'START_FROM_SERVER',
|
|
98
|
+
START_FROM_CLIENT = 'START_FROM_CLIENT',
|
|
99
|
+
START_MANUAL = 'START_MANUAL',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- serializers (Web-API; no node:stream / IncomingMessage) ----
|
|
103
|
+
export abstract class ClientSerializer {
|
|
104
|
+
protected constructor(readonly serializerName: string) {}
|
|
105
|
+
|
|
106
|
+
abstract serializeIntoRequestBody(objectContent: object): Promise<IContent<BodyInit>>;
|
|
107
|
+
|
|
108
|
+
abstract parseFromString(content: IContent<string>): Promise<object>;
|
|
109
|
+
|
|
110
|
+
abstract parseFromResponse(response: Response): Promise<object>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export abstract class ServerSerializer {
|
|
114
|
+
protected constructor(readonly serializerName: string) {}
|
|
115
|
+
|
|
116
|
+
// Web-native: parse the platform Request (was node:http.IncomingMessage); serialize to a string (no stream).
|
|
117
|
+
abstract parse(request: Request): Promise<object>;
|
|
118
|
+
|
|
119
|
+
abstract serializeIntoString(body: object): Promise<IContent<string>>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class JsonClientSerializer extends ClientSerializer {
|
|
123
|
+
constructor() {
|
|
124
|
+
super('json');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
serializeIntoRequestBody(objectContent: object): Promise<IContent<BodyInit>> {
|
|
128
|
+
return Promise.resolve({
|
|
129
|
+
content: JSON.stringify(objectContent),
|
|
130
|
+
contentType: 'application/json',
|
|
131
|
+
serializerName: this.serializerName,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
parseFromString(content: IContent<string>): Promise<object> {
|
|
136
|
+
return Promise.resolve(JSON.parse(content.content) as object);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async parseFromResponse(response: Response): Promise<object> {
|
|
140
|
+
return (await response.json()) as object;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class JsonServerSerializer extends ServerSerializer {
|
|
145
|
+
constructor() {
|
|
146
|
+
super('json');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async parse(request: Request): Promise<object> {
|
|
150
|
+
return (await request.json()) as object;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
serializeIntoString(body: object): Promise<IContent<string>> {
|
|
154
|
+
return Promise.resolve({
|
|
155
|
+
content: JSON.stringify(body),
|
|
156
|
+
contentType: 'application/json',
|
|
157
|
+
serializerName: this.serializerName,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- token: the client/server contract carrier (input/output types + action name + optional serializer) ----
|
|
163
|
+
export class ServerActionToken<TIn extends {}, TOut> {
|
|
164
|
+
readonly inputType!: TIn;
|
|
165
|
+
|
|
166
|
+
readonly outputType!: TOut;
|
|
167
|
+
|
|
168
|
+
readonly actionName: string;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Name of the SERVER module implementing this action. The client hook combines it with
|
|
172
|
+
* the CALLING component's namespace+version (tokens live in the same distributive's Common file, so
|
|
173
|
+
* those always match). Omitted → the calling component's own name (facet pair convention).
|
|
174
|
+
*/
|
|
175
|
+
readonly serverModuleName?: string;
|
|
176
|
+
|
|
177
|
+
/** cache tags this action's QUERY results are labeled with */
|
|
178
|
+
readonly tags?: readonly string[];
|
|
179
|
+
|
|
180
|
+
/** cache tags a successful MUTATION of this action invalidates */
|
|
181
|
+
readonly invalidates?: readonly string[];
|
|
182
|
+
|
|
183
|
+
readonly customSerializer?: ClientSerializer;
|
|
184
|
+
|
|
185
|
+
constructor({
|
|
186
|
+
actionName,
|
|
187
|
+
serverModuleName,
|
|
188
|
+
tags,
|
|
189
|
+
invalidates,
|
|
190
|
+
customSerializer,
|
|
191
|
+
}: {
|
|
192
|
+
readonly actionName: string;
|
|
193
|
+
readonly serverModuleName?: string;
|
|
194
|
+
readonly tags?: readonly string[];
|
|
195
|
+
readonly invalidates?: readonly string[];
|
|
196
|
+
readonly customSerializer?: ClientSerializer;
|
|
197
|
+
}) {
|
|
198
|
+
this.actionName = actionName;
|
|
199
|
+
this.serverModuleName = serverModuleName;
|
|
200
|
+
this.tags = tags;
|
|
201
|
+
this.invalidates = invalidates;
|
|
202
|
+
this.customSerializer = customSerializer;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- client-side transport abstraction + DI tokens ----
|
|
207
|
+
export interface IExecuteServerActionOptions {
|
|
208
|
+
readonly request: IServerActionRequest;
|
|
209
|
+
readonly requestHash: string;
|
|
210
|
+
readonly customSerializer?: ClientSerializer;
|
|
211
|
+
/** cancellation (queries only — a sent mutation must land: the server write already happened) */
|
|
212
|
+
readonly signal?: AbortSignal;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Contract: NEVER rejects with transport/HTTP failures (those fold into result.error) — the ONLY
|
|
216
|
+
// rejection is an abort of `signal`, and aborts are always initiated by the caller (ServerDataManager),
|
|
217
|
+
// which swallows them.
|
|
218
|
+
export abstract class ServerActionExecutor {
|
|
219
|
+
abstract executeServerAction<TOutput>(
|
|
220
|
+
options: IExecuteServerActionOptions,
|
|
221
|
+
): Promise<IServerActionResult<TOutput>>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const CLIENT_SERIALIZER_TOKEN = new SingleDiToken<ClientSerializer>('CLIENT_SERIALIZER_TOKEN');
|
|
225
|
+
|
|
226
|
+
export const SERVER_ACTION_EXECUTOR_TOKEN = new SingleDiToken<ServerActionExecutor>('SERVER_ACTION_EXECUTOR_TOKEN');
|
|
227
|
+
|
|
228
|
+
// ---- route: /api/server-action/<server-module-full-id>/<actionName> ----
|
|
229
|
+
// The full-id already encodes namespace/type/version, so no separate segments and direct file addressing
|
|
230
|
+
// in the private provider (private/modules/<full-id>/). LEADING slash: the route must be origin-absolute
|
|
231
|
+
// (a relative one breaks on any nested browser path — an actual legacy bug).
|
|
232
|
+
export function serverActionRoute(fullId: string, actionName: string): string {
|
|
233
|
+
return `/${['api', 'server-action', fullId, actionName].join('/')}`;
|
|
234
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Constructable, DiContainer, DiService, SingleDiToken } from '@mosetta/di';
|
|
2
|
+
import { parseStoreConnectors } from '@mosetta/stores';
|
|
3
|
+
import type { IStoreConnector } from '@mosetta/stores';
|
|
4
|
+
import { z, ZodType } from 'zod';
|
|
5
|
+
import {
|
|
6
|
+
CALLER_TOKEN,
|
|
7
|
+
IDENTITY_HEADER,
|
|
8
|
+
JsonServerSerializer,
|
|
9
|
+
parseCaller,
|
|
10
|
+
ServerActionToken,
|
|
11
|
+
ServerSerializer,
|
|
12
|
+
} from '../core/core';
|
|
13
|
+
import type { ICaller } from '../core/core';
|
|
14
|
+
|
|
15
|
+
// re-export the DI essentials a coder needs — so a server module imports everything from
|
|
16
|
+
// `@mosetta/server-action/server` (not the legacy @mosetta/di directly).
|
|
17
|
+
export { DiInject } from '@mosetta/di';
|
|
18
|
+
|
|
19
|
+
// The isolate's provider origin — low-level, NON-secret context (an origin string, not a capability),
|
|
20
|
+
// registered by the glue from the isolate env. The framework blesses NO write access with it: a writable
|
|
21
|
+
// content transport is an opt-in service of a trusted module (`@mosetta/content`).
|
|
22
|
+
export const PROVIDER_ORIGIN_TOKEN = new SingleDiToken<string>('PROVIDER_ORIGIN_TOKEN');
|
|
23
|
+
|
|
24
|
+
// The isolate's providers: the host's registry as an isolate may see it — web entries (an
|
|
25
|
+
// address, no secret) and CAPABILITIES (RPC stubs the host granted, `@mosetta/stores`
|
|
26
|
+
// capability.ts): no key ever enters the isolate. An action isolate is granted read-write where
|
|
27
|
+
// the store allows it; per-module grants are the next layer (ADR-0046).
|
|
28
|
+
export const PROVIDERS_TOKEN = new SingleDiToken<readonly IStoreConnector[]>('PROVIDERS_TOKEN');
|
|
29
|
+
|
|
30
|
+
/** a tile host of the site, as an action may know it: its key and where its public side lies */
|
|
31
|
+
export interface ITileHostBase {
|
|
32
|
+
readonly key: string;
|
|
33
|
+
readonly publicBase: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The site's TILE HOSTS, the primary first — where each one's public tiles and descriptor lie (no
|
|
37
|
+
// secret: browsers load from there). What a module reads to see every tile the site may place (the
|
|
38
|
+
// editor's block picker). Always registered: without the host's word, the primary alone, at the
|
|
39
|
+
// provider origin.
|
|
40
|
+
export const TILE_HOSTS_TOKEN = new SingleDiToken<readonly ITileHostBase[]>('TILE_HOSTS_TOKEN');
|
|
41
|
+
|
|
42
|
+
/** the host's list of tile hosts, or the primary alone at `origin` — malformed entries are dropped */
|
|
43
|
+
export function tileHostBasesOf(value: unknown, origin: string | undefined): ITileHostBase[] {
|
|
44
|
+
const listed = Array.isArray(value)
|
|
45
|
+
? (value as Partial<ITileHostBase>[]).filter((h): h is ITileHostBase => typeof h?.key === 'string' && typeof h.publicBase === 'string' && Boolean(h.publicBase))
|
|
46
|
+
: [];
|
|
47
|
+
if (listed.length) return listed.map(({ key, publicBase }) => ({ key, publicBase }));
|
|
48
|
+
return origin ? [{ key: 'host', publicBase: origin }] : [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The project's SECRETS and plain settings, asked for by name — a database token, a mail key, a
|
|
52
|
+
// sender address. The host grants the isolate a capability (an RPC stub, `SECRETS` in its env) that
|
|
53
|
+
// answers with the value of the narrowest scope naming it for THIS module: `/` (the whole project),
|
|
54
|
+
// `/<namespace>`, `/<namespace>/<module>`. No value sits in the env, and a module sees only what it
|
|
55
|
+
// asked for. Where the host grants none, `get` throws — saying so, never a silent null.
|
|
56
|
+
export interface ISecrets {
|
|
57
|
+
/** the value set for `name` (UPPER_SNAKE_CASE), or null when none is */
|
|
58
|
+
get(name: string): Promise<string | null>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const SECRETS_TOKEN = new SingleDiToken<ISecrets>('SECRETS_TOKEN');
|
|
62
|
+
|
|
63
|
+
// The REQUEST an action answers: its address and headers — what the input does not carry, such as
|
|
64
|
+
// the caller's address (`cf-connecting-ip` on Cloudflare) or the page it came from (`referer`). Its
|
|
65
|
+
// body is the action's input, already read.
|
|
66
|
+
export const REQUEST_TOKEN = new SingleDiToken<Request>('REQUEST_TOKEN');
|
|
67
|
+
|
|
68
|
+
const NO_SECRETS: ISecrets = {
|
|
69
|
+
get: (name) => Promise.reject(new Error(`no secrets here: the host runs this module without them (asked for ${name})`)),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** the env's secrets capability, or the refusal when the host granted none */
|
|
73
|
+
function secretsOf(binding: unknown): ISecrets {
|
|
74
|
+
const get = (binding as { get?: unknown } | null | undefined)?.get;
|
|
75
|
+
if (typeof get !== 'function') return NO_SECRETS;
|
|
76
|
+
return { get: (name) => (binding as ISecrets).get(name) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// @mosetta/server-action / server — how a coder authors a server-action module. Bundled into a
|
|
80
|
+
// self-contained Web-API monolith and executed in a Worker Loader isolate. DI + decorators
|
|
81
|
+
// are kept (the `@mosetta/di` container is Web-clean — explicit-token injection, no reflect-metadata).
|
|
82
|
+
|
|
83
|
+
export interface ICreateSchema {
|
|
84
|
+
builder: typeof z;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface IServerActionPayload<TIn> {
|
|
88
|
+
input: TIn;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** what an action's `authorize` decides by */
|
|
92
|
+
export interface IServerActionAccess<TIn> {
|
|
93
|
+
/** the input, parsed and valid */
|
|
94
|
+
input: TIn;
|
|
95
|
+
/** what the host vouches for about the caller — its own words — or null: nobody is vouched for */
|
|
96
|
+
caller: ICaller | null;
|
|
97
|
+
/** the request the action answers (its body already read into `input`) */
|
|
98
|
+
request: Request;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export abstract class ServerAction<TIn, TOut> {
|
|
102
|
+
/**
|
|
103
|
+
* WHO MAY CALL IT — every action says so, explicitly: a server action is an address anybody can
|
|
104
|
+
* reach, and nothing in front of it decides for it. `true` lets the call on (open to anyone:
|
|
105
|
+
* `authorize() { return true; }` — said, not forgotten); `false` refuses it — 401 when nobody is
|
|
106
|
+
* vouched for, 403 when somebody is. The caller comes in the host's words, and the model of who
|
|
107
|
+
* may do what is the distribution's own: the framework knows no roles.
|
|
108
|
+
*/
|
|
109
|
+
abstract authorize(access: IServerActionAccess<TIn>): boolean | Promise<boolean>;
|
|
110
|
+
|
|
111
|
+
abstract action({ input }: IServerActionPayload<TIn>): Promise<TOut>;
|
|
112
|
+
|
|
113
|
+
abstract createSchema(builder: ICreateSchema): ZodType<TIn>;
|
|
114
|
+
|
|
115
|
+
customSerializer(): ServerSerializer | undefined {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Synthetic per-action DI token (derived from the action name) — how the isolate glue resolves the
|
|
121
|
+
// concrete action out of the request-scoped container.
|
|
122
|
+
export function __createSyntheticServerActionToken<TIn extends {}, TOut>(serverActionName: string) {
|
|
123
|
+
return new SingleDiToken<ServerAction<TIn, TOut>>(`${serverActionName}_server_action`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function DiServerAction<TIn extends {}, TOut>(serverActionToken: ServerActionToken<TIn, TOut>) {
|
|
127
|
+
return function DiServerActionDecorator(target: Constructable<ServerAction<TIn, TOut>>) {
|
|
128
|
+
const syntheticToken = __createSyntheticServerActionToken<TIn, TOut>(serverActionToken.actionName);
|
|
129
|
+
DiService(syntheticToken)(target);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface IServerSideDefinition {
|
|
134
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
135
|
+
services: { new (...args: any[]): unknown }[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// the server-action isolate's env (stamped at compose time)
|
|
139
|
+
export interface IServerActionIsolateEnv {
|
|
140
|
+
readonly PROVIDER_ORIGIN?: string;
|
|
141
|
+
readonly PROVIDERS?: unknown;
|
|
142
|
+
/** the site's tile hosts, `{ key, publicBase }[]` — the primary first (see TILE_HOSTS_TOKEN) */
|
|
143
|
+
readonly TILE_HOSTS?: unknown;
|
|
144
|
+
/** the host's secrets capability for this module (see SECRETS_TOKEN) */
|
|
145
|
+
readonly SECRETS?: unknown;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// what the monolith exports and the transport glue calls (same shape as a router module:
|
|
149
|
+
// every module kind carries its own aspect and exposes fetch)
|
|
150
|
+
export interface IServerSide extends IServerSideDefinition {
|
|
151
|
+
fetch(request: Request, env?: IServerActionIsolateEnv): Promise<Response>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
155
|
+
type AnyAction = ServerAction<any, any>;
|
|
156
|
+
|
|
157
|
+
function json(body: unknown, status: number): Response {
|
|
158
|
+
return new Response(JSON.stringify(body), {
|
|
159
|
+
status,
|
|
160
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The WHOLE execution aspect lives HERE and bundles INTO the monolith: fresh DI per
|
|
165
|
+
// request → resolve the action by its synthetic token → parse input (serializer) → Zod-validate →
|
|
166
|
+
// run → serialize. One copy of di per isolate; the coder updates the framework lib (and zod) on
|
|
167
|
+
// their own release cycle — the glue is pure transport. Errors map to 4xx/5xx JSON.
|
|
168
|
+
// Protocol: POST /api/server-action/<full-id>/<actionName>.
|
|
169
|
+
export function defineServerSide({ services }: IServerSideDefinition): IServerSide {
|
|
170
|
+
return {
|
|
171
|
+
services,
|
|
172
|
+
async fetch(request: Request, env?: IServerActionIsolateEnv): Promise<Response> {
|
|
173
|
+
try {
|
|
174
|
+
const actionName = new URL(request.url).pathname.split('/').filter(Boolean).pop() ?? '';
|
|
175
|
+
|
|
176
|
+
const di = new DiContainer();
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
178
|
+
di.registerServices(...(services as any[]));
|
|
179
|
+
|
|
180
|
+
// low-level isolate context (NON-secret provider origin). The framework does NOT register
|
|
181
|
+
// any writable content transport — only a TRUSTED module that includes one in its
|
|
182
|
+
// `services` has write access.
|
|
183
|
+
if (env?.PROVIDER_ORIGIN) {
|
|
184
|
+
di.registerValueOfToken(PROVIDER_ORIGIN_TOKEN, env.PROVIDER_ORIGIN);
|
|
185
|
+
}
|
|
186
|
+
// providers: ALWAYS registered (empty list by default) so the transport's injection
|
|
187
|
+
// never fails; the shape is validated — this is composition data
|
|
188
|
+
di.registerValueOfToken(PROVIDERS_TOKEN, parseStoreConnectors(env?.PROVIDERS, env as Record<string, unknown> | undefined));
|
|
189
|
+
di.registerValueOfToken(TILE_HOSTS_TOKEN, tileHostBasesOf(env?.TILE_HOSTS, env?.PROVIDER_ORIGIN));
|
|
190
|
+
// the caller: what the trust boundary vouches for, in its words (null — nobody)
|
|
191
|
+
const caller = parseCaller(request.headers.get(IDENTITY_HEADER));
|
|
192
|
+
di.registerValueOfToken(CALLER_TOKEN, caller);
|
|
193
|
+
di.registerValueOfToken(SECRETS_TOKEN, secretsOf(env?.SECRETS));
|
|
194
|
+
di.registerValueOfToken(REQUEST_TOKEN, request);
|
|
195
|
+
|
|
196
|
+
let action: AnyAction;
|
|
197
|
+
try {
|
|
198
|
+
action = di.getByToken(__createSyntheticServerActionToken(actionName)) as AnyAction;
|
|
199
|
+
} catch {
|
|
200
|
+
return json({ error: { code: 'SERVER_ACTION_NOT_FOUND', actionName } }, 404);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const serializer: ServerSerializer = action.customSerializer() ?? new JsonServerSerializer();
|
|
204
|
+
const input = await serializer.parse(request);
|
|
205
|
+
|
|
206
|
+
const validation = action.createSchema({ builder: z }).safeParse(input);
|
|
207
|
+
if (!validation.success) {
|
|
208
|
+
return json({ error: { code: 'SERVER_ACTION_VALIDATION', issues: validation.error.issues } }, 400);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// the action decides who may call it — before it runs, by the valid input and the caller
|
|
212
|
+
if (!(await action.authorize({ input: validation.data as unknown, caller, request }))) {
|
|
213
|
+
return caller
|
|
214
|
+
? json({ error: { code: 'SERVER_ACTION_FORBIDDEN', actionName } }, 403)
|
|
215
|
+
: json({ error: { code: 'SERVER_ACTION_UNAUTHORIZED', actionName } }, 401);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// the action's TOut is erased at this generic call site — pin to unknown for the serializer
|
|
219
|
+
const output = (await action.action({ input: validation.data as unknown })) as unknown;
|
|
220
|
+
const serialized = await serializer.serializeIntoString(output as object);
|
|
221
|
+
return new Response(serialized.content, {
|
|
222
|
+
status: 200,
|
|
223
|
+
headers: { 'content-type': serialized.contentType },
|
|
224
|
+
});
|
|
225
|
+
} catch (e) {
|
|
226
|
+
return json(
|
|
227
|
+
{ error: { code: 'SERVER_ACTION_FAILED', message: String((e as Error)?.message ?? e) } },
|
|
228
|
+
500,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export { CALLER_TOKEN, IDENTITY_HEADER, parseCaller } from '../core/core';
|
|
236
|
+
export type { ICaller } from '../core/core';
|