spfn 0.3.0-beta.13 → 0.3.0-beta.15
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/README.md +45 -8
- package/dist/index.js +359 -216
- package/dist/surface.d.ts +228 -0
- package/dist/surface.js +497 -0
- package/package.json +14 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a surface manifest describes and a call sends, as the client sees it.
|
|
3
|
+
*/
|
|
4
|
+
type SurfaceEffect = 'read' | 'write' | 'destructive';
|
|
5
|
+
interface SurfaceModuleDescriptor {
|
|
6
|
+
id: string;
|
|
7
|
+
source: string;
|
|
8
|
+
contractVersion: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
}
|
|
11
|
+
interface SurfaceCommandDescriptor {
|
|
12
|
+
name: string;
|
|
13
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
14
|
+
path: string;
|
|
15
|
+
module?: string;
|
|
16
|
+
summary?: string;
|
|
17
|
+
effect?: SurfaceEffect;
|
|
18
|
+
scopes?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Set when the app announced module metadata that failed validation, so
|
|
21
|
+
* this command's effect is unknown rather than absent. Callers must treat
|
|
22
|
+
* it as needing confirmation.
|
|
23
|
+
*/
|
|
24
|
+
metadataRejected?: boolean;
|
|
25
|
+
input: {
|
|
26
|
+
params?: Record<string, unknown>;
|
|
27
|
+
query?: Record<string, unknown>;
|
|
28
|
+
body?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
interface SurfaceManifest {
|
|
32
|
+
manifestVersion: 1;
|
|
33
|
+
modules?: SurfaceModuleDescriptor[];
|
|
34
|
+
commands: SurfaceCommandDescriptor[];
|
|
35
|
+
}
|
|
36
|
+
interface SurfaceCallInput {
|
|
37
|
+
params: Record<string, string>;
|
|
38
|
+
query: Record<string, string>;
|
|
39
|
+
body: unknown | undefined;
|
|
40
|
+
}
|
|
41
|
+
interface SurfaceResponse {
|
|
42
|
+
status: number;
|
|
43
|
+
body: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Manifest trust
|
|
48
|
+
*
|
|
49
|
+
* The manifest is the app's own description of itself, and the client turns
|
|
50
|
+
* it into requests carrying a credential. So it is read as untrusted input:
|
|
51
|
+
* every command is checked against the surface's namespace before it can
|
|
52
|
+
* become a request, and what cannot be used is dropped with a warning rather
|
|
53
|
+
* than costing the caller every other command on the surface.
|
|
54
|
+
*
|
|
55
|
+
* Warnings go to the caller's sink with a structured description beside the
|
|
56
|
+
* message, so a CLI can word them in its own terms; nothing here writes to a
|
|
57
|
+
* terminal.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** What the client dropped from a manifest, and why. */
|
|
61
|
+
type SurfaceWarning = {
|
|
62
|
+
kind: 'command';
|
|
63
|
+
reason: string;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'metadata';
|
|
66
|
+
command: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
} | {
|
|
69
|
+
kind: 'modules';
|
|
70
|
+
reason: string;
|
|
71
|
+
} | {
|
|
72
|
+
kind: 'module';
|
|
73
|
+
reason: string;
|
|
74
|
+
};
|
|
75
|
+
type SurfaceWarningSink = (message: string, warning: SurfaceWarning) => void;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* HTTP client for an app's command surface
|
|
79
|
+
*
|
|
80
|
+
* Talks to the running (usually deployed) app: fetches
|
|
81
|
+
* `GET <namespace>/_manifest` for command discovery and invokes commands
|
|
82
|
+
* against their declared method and path. The server owns validation — the
|
|
83
|
+
* client sends what its caller gave it and relays the server's answer.
|
|
84
|
+
*
|
|
85
|
+
* `spfn ops` is this client on `/_ops`; any namespace a `defineSurface`
|
|
86
|
+
* serves is reached the same way. The client writes nothing to a terminal:
|
|
87
|
+
* what it drops from a manifest goes to `onWarning`, and what it refuses is
|
|
88
|
+
* thrown — an app URL that would carry the credential in the clear among it.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* import { createSurfaceClient } from 'spfn/surface';
|
|
93
|
+
*
|
|
94
|
+
* const client = createSurfaceClient({
|
|
95
|
+
* appUrl: 'https://app.example',
|
|
96
|
+
* namespace: '/_app',
|
|
97
|
+
* token: async () => issueToken(),
|
|
98
|
+
* });
|
|
99
|
+
* const answer = await client.call('pageAdd', { params: {}, query: {}, body: { title: 'Hello' } });
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
interface SurfaceClientOptions {
|
|
104
|
+
/** The app's URL, base path included. https, or http against loopback. */
|
|
105
|
+
appUrl: string;
|
|
106
|
+
/** The surface's namespace, e.g. `/_app`. Its manifest is `<namespace>/_manifest`. */
|
|
107
|
+
namespace: string;
|
|
108
|
+
/** The bearer credential, or a function resolving it before each request. */
|
|
109
|
+
token: string | (() => Promise<string>);
|
|
110
|
+
/** Told about every command or module the client drops. Default: silent. */
|
|
111
|
+
onWarning?: SurfaceWarningSink;
|
|
112
|
+
}
|
|
113
|
+
interface SurfaceClient {
|
|
114
|
+
/** Fetch the manifest, keeping only the commands this client can call. */
|
|
115
|
+
manifest(): Promise<SurfaceManifest>;
|
|
116
|
+
/** One command's usage text, from a manifest already fetched. */
|
|
117
|
+
describe(manifest: SurfaceManifest, name: string): string;
|
|
118
|
+
/** Fetch the manifest and invoke the named command. */
|
|
119
|
+
call(name: string, input: SurfaceCallInput): Promise<SurfaceResponse>;
|
|
120
|
+
/** Invoke a command taken from a manifest already fetched. */
|
|
121
|
+
invoke(command: SurfaceCommandDescriptor, input: SurfaceCallInput): Promise<SurfaceResponse>;
|
|
122
|
+
}
|
|
123
|
+
/** The manifest request answered something other than 200. */
|
|
124
|
+
declare class SurfaceManifestError extends Error {
|
|
125
|
+
readonly status: number;
|
|
126
|
+
constructor(message: string, status: number);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Append an absolute path to the app URL, keeping whatever base path the
|
|
130
|
+
* caller gave.
|
|
131
|
+
*
|
|
132
|
+
* Concatenation rather than `new URL(path, appUrl)`, because an absolute path
|
|
133
|
+
* resolved against a base replaces it: an app mounted at
|
|
134
|
+
* `https://example.com/api` would be called at `https://example.com/_ops/...`.
|
|
135
|
+
* Every request goes through here so the two halves of the ops CLI — the ops
|
|
136
|
+
* calls and the administrator sign-in — agree about where the app is.
|
|
137
|
+
*/
|
|
138
|
+
declare function joinUrl(appUrl: string, path: string): string;
|
|
139
|
+
/**
|
|
140
|
+
* Create a client for one surface of one app. The app URL and namespace are
|
|
141
|
+
* checked here, so a client that exists can only send its credential where
|
|
142
|
+
* it is safe to.
|
|
143
|
+
*/
|
|
144
|
+
declare function createSurfaceClient(options: SurfaceClientOptions): SurfaceClient;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Command paths: whether a manifest's path template is safe to request, and
|
|
148
|
+
* the request path a call builds from it.
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Substitute `:name` segments from params. A missing parameter fails here,
|
|
153
|
+
* before any request leaves the machine.
|
|
154
|
+
*
|
|
155
|
+
* The template is checked for namespace escape, and each value for climbing
|
|
156
|
+
* out of its own segment. The substituted result is not re-checked: by then
|
|
157
|
+
* every value is encoded, and reading an operator's own `%` or `/` back as an
|
|
158
|
+
* attempted escape would refuse data the app accepts.
|
|
159
|
+
*/
|
|
160
|
+
declare function buildCommandPath(command: SurfaceCommandDescriptor, params: Record<string, string>, query: Record<string, string>, namespace: string): string;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Terminal-safe rendering of app-supplied text
|
|
164
|
+
*
|
|
165
|
+
* Its own module because both the manifest client and the usage renderer need
|
|
166
|
+
* it, and the client must not import the renderer to get it.
|
|
167
|
+
*/
|
|
168
|
+
/**
|
|
169
|
+
* Strip what a terminal would act on rather than show.
|
|
170
|
+
*
|
|
171
|
+
* Every string rendered here — a field's description, its pattern, a command's
|
|
172
|
+
* name — is the app's, and it is written straight to the operator's terminal.
|
|
173
|
+
* Escape sequences left in it can clear the screen or redraw the lines above,
|
|
174
|
+
* so what the operator reads is no longer what the CLI wrote. Control
|
|
175
|
+
* characters are replaced rather than dropped, so text that contained them
|
|
176
|
+
* still reads as suspicious instead of quietly shrinking.
|
|
177
|
+
*/
|
|
178
|
+
declare function plain(value: string): string;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Render a surface command's input schemas as usage text.
|
|
182
|
+
*
|
|
183
|
+
* The manifest carries each command's TypeBox schemas as plain JSON Schema,
|
|
184
|
+
* so the terminal can tell an operator what a command takes without the app's
|
|
185
|
+
* source. This turns that JSON into the shape a `--help` reader expects:
|
|
186
|
+
* one line per field, with its type, whether it is required, and whatever
|
|
187
|
+
* constraints the schema states.
|
|
188
|
+
*
|
|
189
|
+
* The server remains the authority on what is valid — this only reports what
|
|
190
|
+
* the server said.
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The whole usage block for one command: its method and path, then a section
|
|
195
|
+
* per input kind. A command taking no input says so rather than printing
|
|
196
|
+
* nothing.
|
|
197
|
+
*
|
|
198
|
+
* `invoke` is the command line that calls it — `spfn ops call` for the ops
|
|
199
|
+
* CLI — and adds an example invocation line; a library caller has none.
|
|
200
|
+
*/
|
|
201
|
+
declare function renderCommandUsage(command: SurfaceCommandDescriptor, invoke?: string): string;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The transport rule every surface request obeys: https, or http against the
|
|
205
|
+
* machine itself.
|
|
206
|
+
*
|
|
207
|
+
* Every request a surface client makes carries a credential — an ops token, a
|
|
208
|
+
* signed token, an administrator's password on `spfn ops token issue`. A stale
|
|
209
|
+
* app URL left over from a local run, or a typo, is enough to put it on the
|
|
210
|
+
* wire unencrypted, and nothing about the answer would say so. The client
|
|
211
|
+
* refuses such a URL when it is created; `spfn ops` asks the same question
|
|
212
|
+
* first so it can render the refusal its own way.
|
|
213
|
+
*/
|
|
214
|
+
/** Why an app URL may not carry a credential, or null when it may. */
|
|
215
|
+
type TransportRefusal = 'cleartext-http' | 'not-http';
|
|
216
|
+
/**
|
|
217
|
+
* Where `http` is not a mistake: a machine talking to itself, which has no
|
|
218
|
+
* certificate to present and no network for anyone to listen on.
|
|
219
|
+
*
|
|
220
|
+
* The scaffold's own API runs on `http://localhost:8790`, so local development
|
|
221
|
+
* has to keep working — but that is the whole of the exception.
|
|
222
|
+
*/
|
|
223
|
+
declare function isLoopback(hostname: string): boolean;
|
|
224
|
+
declare function transportRefusalOf(url: URL): TransportRefusal | null;
|
|
225
|
+
/** Throw unless `appUrl` is an absolute https URL, or http against loopback. */
|
|
226
|
+
declare function assertTransportSafe(appUrl: string): void;
|
|
227
|
+
|
|
228
|
+
export { type SurfaceCallInput, type SurfaceClient, type SurfaceClientOptions, type SurfaceCommandDescriptor, type SurfaceEffect, type SurfaceManifest, SurfaceManifestError, type SurfaceModuleDescriptor, type SurfaceResponse, type SurfaceWarning, type SurfaceWarningSink, type TransportRefusal, assertTransportSafe, buildCommandPath, createSurfaceClient, isLoopback, joinUrl, plain, renderCommandUsage, transportRefusalOf };
|