@mirrorstack-ai/app-module-client 0.5.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 +152 -0
- package/LICENSE +202 -0
- package/README.md +488 -0
- package/dist/base-url.d.ts +39 -0
- package/dist/base-url.js +51 -0
- package/dist/base-url.js.map +1 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.js +85 -0
- package/dist/client.js.map +1 -0
- package/dist/error.d.ts +29 -0
- package/dist/error.js +100 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/next/auth-routes.d.ts +82 -0
- package/dist/next/auth-routes.js +164 -0
- package/dist/next/auth-routes.js.map +1 -0
- package/dist/next/index.d.ts +12 -0
- package/dist/next/index.js +13 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/module-proxy-routes.d.ts +81 -0
- package/dist/next/module-proxy-routes.js +77 -0
- package/dist/next/module-proxy-routes.js.map +1 -0
- package/dist/plugin.d.ts +39 -0
- package/dist/plugin.js +39 -0
- package/dist/plugin.js.map +1 -0
- package/dist/response.d.ts +13 -0
- package/dist/response.js +80 -0
- package/dist/response.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +8 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/member-sessions.d.ts +80 -0
- package/dist/server/member-sessions.js +162 -0
- package/dist/server/member-sessions.js.map +1 -0
- package/dist/transport.d.ts +98 -0
- package/dist/transport.js +299 -0
- package/dist/transport.js.map +1 -0
- package/dist/web/cache.d.ts +33 -0
- package/dist/web/cache.js +130 -0
- package/dist/web/cache.js.map +1 -0
- package/dist/web/component-mount.d.ts +23 -0
- package/dist/web/component-mount.js +111 -0
- package/dist/web/component-mount.js.map +1 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +7 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/localized-text.d.ts +10 -0
- package/dist/web/localized-text.js +36 -0
- package/dist/web/localized-text.js.map +1 -0
- package/dist/web/react.d.ts +18 -0
- package/dist/web/react.js +111 -0
- package/dist/web/react.js.map +1 -0
- package/dist/web/runtime.d.ts +60 -0
- package/dist/web/runtime.js +73 -0
- package/dist/web/runtime.js.map +1 -0
- package/dist/web/subpath.d.ts +22 -0
- package/dist/web/subpath.js +50 -0
- package/dist/web/subpath.js.map +1 -0
- package/dist/web/types.d.ts +129 -0
- package/dist/web/types.js +2 -0
- package/dist/web/types.js.map +1 -0
- package/dist/web/use-now.d.ts +7 -0
- package/dist/web/use-now.js +22 -0
- package/dist/web/use-now.js.map +1 -0
- package/dist/web/use-platform-unsaved-state.d.ts +10 -0
- package/dist/web/use-platform-unsaved-state.js +35 -0
- package/dist/web/use-platform-unsaved-state.js.map +1 -0
- package/package.json +88 -0
package/README.md
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# @mirrorstack-ai/app-module-client
|
|
2
|
+
|
|
3
|
+
Framework-neutral, typed composition for calling MirrorStack application
|
|
4
|
+
modules. The package builds dispatch URLs, applies injected transport policy,
|
|
5
|
+
and composes explicitly registered module plugins into one app client. It is
|
|
6
|
+
ESM-only and has zero runtime dependencies. Optional UI adapters live behind
|
|
7
|
+
explicit entry points with optional peer dependencies.
|
|
8
|
+
|
|
9
|
+
V1 deliberately has no server-framework adapter, module-specific endpoint or
|
|
10
|
+
domain/query hooks, or implicit plugin discovery. Optional generic React
|
|
11
|
+
lifecycle helpers remain isolated behind the `./web/react` entry point.
|
|
12
|
+
|
|
13
|
+
> Version `0.3.0` composes an app dispatch root as
|
|
14
|
+
> `<baseUrl>/<scope>/<moduleRef>/<path>` — scope **before** module — matching
|
|
15
|
+
> the platform contract for custom web apps, and adds `platformBaseUrl()` to
|
|
16
|
+
> build that base from `MIRRORSTACK_API_URL` and `MIRRORSTACK_APP_SLUG`. A host
|
|
17
|
+
> that pinned the previous `<baseUrl>/<moduleRef>/<scope>/<path>` shape in a
|
|
18
|
+
> test or a BFF route table must update it. The `./web` runtime receives a
|
|
19
|
+
> module root from its host and is unchanged.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add @mirrorstack-ai/app-module-client
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package is published to [npmjs.com](https://www.npmjs.com/package/@mirrorstack-ai/app-module-client)
|
|
28
|
+
and mirrored to GitHub Packages. A project that already resolves the
|
|
29
|
+
`@mirrorstack-ai` scope from GitHub Packages can keep doing so, providing
|
|
30
|
+
authentication through the environment (do not commit a token):
|
|
31
|
+
|
|
32
|
+
```ini
|
|
33
|
+
# .npmrc
|
|
34
|
+
@mirrorstack-ai:registry=https://npm.pkg.github.com
|
|
35
|
+
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Node.js 20 or newer is required.
|
|
39
|
+
|
|
40
|
+
## Compose an app client
|
|
41
|
+
|
|
42
|
+
Each module publishes a plugin from its own client package. The host imports
|
|
43
|
+
the plugins it uses and registers them under explicit, typed local names:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createAppClient } from "@mirrorstack-ai/app-module-client";
|
|
47
|
+
import { userCore } from "@mirrorstack-ai/user-core-client/plugin";
|
|
48
|
+
|
|
49
|
+
const client = createAppClient({
|
|
50
|
+
// A same-origin BFF base is recommended for browser applications.
|
|
51
|
+
baseUrl: "/api/mirrorstack/modules",
|
|
52
|
+
modules: {
|
|
53
|
+
user: userCore(),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const me = await client.modules.user.getMe();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The `user` object key is only the host's typed access name. The plugin's
|
|
61
|
+
`moduleRef` controls the dispatch URL. Registration is static and explicit:
|
|
62
|
+
the package does not scan `node_modules`, load plugins dynamically, or infer
|
|
63
|
+
installed modules.
|
|
64
|
+
|
|
65
|
+
Catalog slug references use the same canonical contract as the Go Module SDK:
|
|
66
|
+
one to 16 ASCII characters matching `[a-z][a-z0-9-]{0,15}`. Installed-module
|
|
67
|
+
UUID references remain accepted as well.
|
|
68
|
+
|
|
69
|
+
## Author a module client
|
|
70
|
+
|
|
71
|
+
Endpoint methods, request/response types, and any framework-specific hooks stay
|
|
72
|
+
in the module's own client package. That package defines its typed surface with
|
|
73
|
+
`defineModuleClient`:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { defineModuleClient } from "@mirrorstack-ai/app-module-client";
|
|
77
|
+
|
|
78
|
+
export interface Asset {
|
|
79
|
+
id: string;
|
|
80
|
+
title: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AssetLibraryClientOptions {
|
|
84
|
+
// An installed module can be addressed by its catalog slug or UUID.
|
|
85
|
+
moduleRef?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const assetLibrary = ({
|
|
89
|
+
moduleRef = "asset-library",
|
|
90
|
+
}: AssetLibraryClientOptions = {}) =>
|
|
91
|
+
defineModuleClient({
|
|
92
|
+
moduleRef,
|
|
93
|
+
create({ public: publicScope, platform }) {
|
|
94
|
+
return {
|
|
95
|
+
getAsset: (id: string) => publicScope.get<Asset>(`/assets/${id}`),
|
|
96
|
+
archiveAsset: (id: string) =>
|
|
97
|
+
platform.post(`/assets/${id}/archive`, { responseType: "void" }),
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Both scopes expose typed request helpers. For example,
|
|
104
|
+
`.get<T>(path, { query?, responseType? })` performs a GET,
|
|
105
|
+
`.post<T>(path, { json: payload })` sends JSON, and `.url(path, query)` builds a
|
|
106
|
+
navigation URL without making a request. A module package may wrap those
|
|
107
|
+
primitives however its own API requires.
|
|
108
|
+
|
|
109
|
+
JSON is the default response type and requires a JSON body. Endpoints returning
|
|
110
|
+
`204`, `205`, or another empty success must explicitly use
|
|
111
|
+
`responseType: "void"`; this keeps the declared return type honest.
|
|
112
|
+
When the `json` option is present, the transport owns both serialization and
|
|
113
|
+
`Content-Type: application/json`; merged header sources cannot override it.
|
|
114
|
+
Raw `body` requests retain caller-owned media types.
|
|
115
|
+
|
|
116
|
+
Only `public` and `platform` are client scopes. MirrorStack internal routes are
|
|
117
|
+
intentionally not represented.
|
|
118
|
+
|
|
119
|
+
## Base URL model
|
|
120
|
+
|
|
121
|
+
`baseUrl` identifies the app dispatch root. The core appends the scope, the
|
|
122
|
+
plugin's module reference, and the endpoint path — scope first:
|
|
123
|
+
|
|
124
|
+
```text
|
|
125
|
+
<baseUrl>/<scope>/<moduleRef>/<path>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
For a direct platform connection, the canonical base carries the app
|
|
129
|
+
reference, and the platform serves every installed module beneath it:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
https://api.<org-domain>/v1/apps/app/<appRef>
|
|
133
|
+
https://api.<org-domain>/v1/apps/app/<appRef>/public/user-core/me
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Build that base with `platformBaseUrl`. A custom web app reads
|
|
137
|
+
`MIRRORSTACK_API_URL` and `MIRRORSTACK_APP_SLUG` from its environment. The
|
|
138
|
+
slug is the **app** slug as shown in the console URL
|
|
139
|
+
(`apps.mirrorstack.ai/apps/<slug>`) — lowercase letters, digits, and hyphens,
|
|
140
|
+
1–39 characters, may start with a digit — not a module's catalog slug. The
|
|
141
|
+
helper validates both inputs (absolute HTTP(S) URL without credentials, query,
|
|
142
|
+
or fragment; app slug), strips trailing slashes, and fails at startup instead
|
|
143
|
+
of as a 404 on the first request:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { createAppClient, platformBaseUrl } from "@mirrorstack-ai/app-module-client";
|
|
147
|
+
import { userCore } from "@mirrorstack-ai/user-core-client/plugin";
|
|
148
|
+
|
|
149
|
+
const client = createAppClient({
|
|
150
|
+
baseUrl: platformBaseUrl({
|
|
151
|
+
apiUrl: process.env.MIRRORSTACK_API_URL!, // https://api.<org-domain>
|
|
152
|
+
appSlug: process.env.MIRRORSTACK_APP_SLUG!, // <appRef>
|
|
153
|
+
}),
|
|
154
|
+
modules: { user: userCore() },
|
|
155
|
+
});
|
|
156
|
+
// client.modules.user.getMe() → GET https://api.<org-domain>/v1/apps/app/<appRef>/public/user-core/me
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`<org-domain>` is the organization's configured domain; it is not required to
|
|
160
|
+
be `mirrorstack.ai`.
|
|
161
|
+
|
|
162
|
+
For a browser, a same-origin BFF that forwards to that base keeps the upstream
|
|
163
|
+
app reference and any server-side credentials out of client code:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
/api/mirrorstack/modules/public/user-core/me
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Keep any deployment prefix in `baseUrl`; endpoint paths are appended rather
|
|
170
|
+
than resolved from the origin root.
|
|
171
|
+
|
|
172
|
+
## Sign-in for a custom app: `./server` and `./next`
|
|
173
|
+
|
|
174
|
+
A custom app runs on its own origin, so the auth provider's session cookie
|
|
175
|
+
never reaches it. The platform issues the app its own **member session**
|
|
176
|
+
instead: the app sends the browser to the provider with a one-time state, the
|
|
177
|
+
provider returns a one-time code, and the app exchanges the pair on the app's
|
|
178
|
+
control plane for an `mss1_` credential that every installed module accepts as
|
|
179
|
+
`Authorization: Bearer`. Those routes are dispatch's, not a module's, which is
|
|
180
|
+
why the helpers live here beside `platformBaseUrl`.
|
|
181
|
+
|
|
182
|
+
`./server` is framework-neutral:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { memberSessions } from "@mirrorstack-ai/app-module-client/server";
|
|
186
|
+
|
|
187
|
+
const sessions = memberSessions({ apiUrl, appSlug });
|
|
188
|
+
const state = sessions.newState(); // keep it in an HttpOnly cookie
|
|
189
|
+
const session = await sessions.exchange(code, state); // { credential, identity, expiresAt }
|
|
190
|
+
await sessions.revoke(session.credential); // "revoked" | "alreadyInvalid" | "unavailable"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`./next` (optional peer `next`) turns that into ready App Router route
|
|
194
|
+
handlers, so an app's auth routes are one export each:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// src/lib/auth.ts
|
|
198
|
+
import { createAuthRoutes } from "@mirrorstack-ai/app-module-client/next";
|
|
199
|
+
export const auth = createAuthRoutes({ apiUrl, appSlug, provider: client.modules.userCore });
|
|
200
|
+
|
|
201
|
+
// src/app/api/auth/start/route.ts export const { GET } = auth.start;
|
|
202
|
+
// src/app/api/auth/callback/route.ts export const { GET } = auth.callback;
|
|
203
|
+
// src/app/api/auth/logout/route.ts export const { POST } = auth.logout;
|
|
204
|
+
// anywhere server-side const credential = await auth.readMemberCredential();
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The state is issued and stored in the same request that hands out the start
|
|
208
|
+
URL, taken once before the code is redeemed, and never compared to anything in
|
|
209
|
+
the query; the session cookie is HttpOnly on the app's origin, `Secure` when
|
|
210
|
+
the request is HTTPS, and cleared on logout only after the platform has
|
|
211
|
+
revoked the credential. `handoffParam` defaults to User Core's `ms_handoff`;
|
|
212
|
+
pass the provider's own constant when its client exports one.
|
|
213
|
+
|
|
214
|
+
### The module proxy
|
|
215
|
+
|
|
216
|
+
An HttpOnly cookie is unreadable to script by design, so the browser cannot
|
|
217
|
+
call the platform itself — and that is the *only* thing it is missing.
|
|
218
|
+
`createModuleProxyRoutes` is the one server hop that supplies it, so the
|
|
219
|
+
browser keeps using the real module client instead of the app hand-writing an
|
|
220
|
+
endpoint per operation:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// src/app/api/mirrorstack/modules/[...path]/route.ts
|
|
224
|
+
import { createModuleProxyRoutes } from "@mirrorstack-ai/app-module-client/next";
|
|
225
|
+
export const runtime = "nodejs";
|
|
226
|
+
export const { GET, POST, PUT, PATCH, DELETE } = createModuleProxyRoutes({
|
|
227
|
+
apiUrl,
|
|
228
|
+
appSlug,
|
|
229
|
+
readMemberCredential: auth.readMemberCredential,
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Point the browser client's `baseUrl` at that mount path. Take
|
|
234
|
+
`readMemberCredential` from `createAuthRoutes` rather than re-deriving it, so
|
|
235
|
+
the proxy and sign-in cannot disagree about where the session lives.
|
|
236
|
+
|
|
237
|
+
It forwards; it does not decide — authorization stays the platform's and the
|
|
238
|
+
modules' answer on every request, and the app's own session cookie is never
|
|
239
|
+
replayed upstream. Two details it handles that are invisible until they bite:
|
|
240
|
+
`duplex: "half"`, which undici requires whenever the body is a stream (so
|
|
241
|
+
without it every upload throws before a byte leaves), and dropping
|
|
242
|
+
`content-encoding` / `content-length` from the response, where a copied length
|
|
243
|
+
describes bytes that no longer exist and the request hangs rather than failing.
|
|
244
|
+
|
|
245
|
+
## Transport and platform authentication
|
|
246
|
+
|
|
247
|
+
`createAppClient` accepts injected `fetch`, `headers`, and `credentials`
|
|
248
|
+
options so the host controls transport without coupling module packages to a
|
|
249
|
+
runtime or framework. `credentials` defaults to `"include"`; set it explicitly
|
|
250
|
+
only when the host needs a different standard Fetch credentials policy.
|
|
251
|
+
|
|
252
|
+
Parsed success bodies, parsed error bodies, and authentication-refresh error
|
|
253
|
+
inspection are bounded to one mebibyte by default. Set `maxResponseBytes` to a
|
|
254
|
+
positive byte count when a host has a narrower or explicitly larger contract.
|
|
255
|
+
The limit is enforced while streaming even when `Content-Length` is missing or
|
|
256
|
+
incorrect. A raw scoped `.fetch()` or `responseType: "response"` transfers body
|
|
257
|
+
ownership and size enforcement to its caller.
|
|
258
|
+
|
|
259
|
+
Platform requests have an additional, deliberately narrow authentication
|
|
260
|
+
path. A host may provide `platformAuth.getAccessToken` and optionally
|
|
261
|
+
`platformAuth.refreshAccessToken`; these callbacks are considered only for the
|
|
262
|
+
`platform` scope. Public requests never receive that token. Without
|
|
263
|
+
`platformAuth`, a platform request can instead go through a same-origin BFF
|
|
264
|
+
that owns authentication.
|
|
265
|
+
|
|
266
|
+
Those callbacks return only an end-user access token that is safe for that
|
|
267
|
+
host to send as an `Authorization: Bearer` value. They must never return or
|
|
268
|
+
expose `X-MS-Platform-Token`, `X-MS-Internal-Secret`, a delegation credential,
|
|
269
|
+
member assertion, signing key, or any other server credential.
|
|
270
|
+
|
|
271
|
+
When `platformAuth` is present, `getAccessToken` must return a non-empty token;
|
|
272
|
+
otherwise the platform request fails before any network call. A configured
|
|
273
|
+
refresh callback must likewise return a non-empty replacement token before the
|
|
274
|
+
client will retry. Omit `platformAuth` when a same-origin BFF authenticates the
|
|
275
|
+
request itself.
|
|
276
|
+
|
|
277
|
+
When `refreshAccessToken` is configured, a platform request may refresh and
|
|
278
|
+
retry once only after a `401` response whose dispatch error code is
|
|
279
|
+
`token_expired` or `token_missing`. Automatic replay is limited to safe
|
|
280
|
+
`GET`, `HEAD`, and `OPTIONS` requests. The client never automatically replays
|
|
281
|
+
`POST`, `PUT`, `PATCH`, or `DELETE`, an arbitrary module `401`, a public
|
|
282
|
+
request, or a request with a non-replayable streaming body.
|
|
283
|
+
|
|
284
|
+
Scoped requests always use Fetch's `redirect: "error"` policy and reject a
|
|
285
|
+
caller-supplied redirect override. A module-controlled redirect therefore
|
|
286
|
+
cannot carry cookies or a platform Bearer to a URL outside the registered
|
|
287
|
+
module and scope.
|
|
288
|
+
|
|
289
|
+
Callers cannot set `Authorization` manually on platform requests, and all
|
|
290
|
+
caller-provided `X-MS-*` headers are rejected. In browser code, never make a
|
|
291
|
+
delegation credential, member assertion, signing key, or server secret available
|
|
292
|
+
to this package—or to any other client-side code. Keep those values behind the
|
|
293
|
+
BFF boundary.
|
|
294
|
+
|
|
295
|
+
## Error handling
|
|
296
|
+
|
|
297
|
+
The parsed request helpers (`get`, `post`, `put`, `patch`, `delete`, and
|
|
298
|
+
`request`) reject non-successful responses with `ModuleClientError`. Catch the
|
|
299
|
+
class to inspect transport-neutral context without coupling a module package to
|
|
300
|
+
a framework:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { ModuleClientError } from "@mirrorstack-ai/app-module-client";
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
await client.modules.user.getMe();
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (!(error instanceof ModuleClientError)) throw error;
|
|
309
|
+
|
|
310
|
+
console.error({
|
|
311
|
+
status: error.status,
|
|
312
|
+
code: error.code,
|
|
313
|
+
details: error.details,
|
|
314
|
+
body: error.body,
|
|
315
|
+
requestId: error.requestId,
|
|
316
|
+
moduleRef: error.moduleRef,
|
|
317
|
+
scope: error.scope,
|
|
318
|
+
path: error.path,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The error preserves the HTTP status and parsed error data when available, plus
|
|
324
|
+
the module/scope/path context that produced the request. Authentication refresh
|
|
325
|
+
is handled before the final error is exposed and is subject to the one-retry,
|
|
326
|
+
replay-safe rule above. The lower-level scoped `.fetch()` deliberately returns
|
|
327
|
+
the raw `Response` and leaves HTTP-status handling to its caller. Network,
|
|
328
|
+
abort, and local validation failures remain their native errors; they are not
|
|
329
|
+
wrapped in `ModuleClientError`.
|
|
330
|
+
|
|
331
|
+
## Responsibilities
|
|
332
|
+
|
|
333
|
+
| Layer | Owns | Does not own |
|
|
334
|
+
| --- | --- | --- |
|
|
335
|
+
| This package | Typed plugin composition, dispatch URL construction, `public`/`platform` request primitives, injected fetch/headers/credentials, shared errors, generic web/React lifecycle helpers | Module endpoint catalogs, domain response models and query hooks, auth issuance, internal routes |
|
|
336
|
+
| A module client package | Its endpoint methods and types; optional framework hooks in its own explicit entry points | Other modules, app authentication policy, plugin discovery |
|
|
337
|
+
| A browser host | Explicit plugin selection, same-origin base URL, browser-safe transport and access-token integration | Delegation credentials, member assertions, signing keys, server secrets, module contracts |
|
|
338
|
+
| A server host or BFF | Upstream app base/reference, cookies or access tokens, trusted assertions, runtime-specific forwarding | Exposing trusted credentials to browser code, re-declaring module contracts |
|
|
339
|
+
|
|
340
|
+
## Discovery status
|
|
341
|
+
|
|
342
|
+
There is currently no CLI or Module SDK integration that generates or
|
|
343
|
+
auto-discovers client plugins. Installing a module does not add client code to
|
|
344
|
+
an application automatically. Hosts must install each module's client package,
|
|
345
|
+
import its plugin, and register it in the `modules` object themselves.
|
|
346
|
+
|
|
347
|
+
`@mirrorstack-ai/user-core-client/plugin` is the first hand-authored
|
|
348
|
+
first-party integration. Cross-repository canaries validate its typed
|
|
349
|
+
`/public/me` call against the canonical platform URL shape,
|
|
350
|
+
`https://api.<org-domain>/v1/apps/app/<appRef>/public/user-core/me`.
|
|
351
|
+
|
|
352
|
+
## Mounted module web surfaces
|
|
353
|
+
|
|
354
|
+
Use `@mirrorstack-ai/app-module-client/web` inside a module-owned web bundle. It
|
|
355
|
+
provides the platform mount contract, scoped module transports, localized-text
|
|
356
|
+
selection, and mount-local subpath state without introducing React as a
|
|
357
|
+
dependency.
|
|
358
|
+
|
|
359
|
+
~~~ts
|
|
360
|
+
import { createModuleWebTransports } from "@mirrorstack-ai/app-module-client/web";
|
|
361
|
+
|
|
362
|
+
const api = createModuleWebTransports({
|
|
363
|
+
moduleRef: "user-core",
|
|
364
|
+
apiBase: context.apiBase,
|
|
365
|
+
fetch: context.fetch,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const users = await api.platform.get("/users");
|
|
369
|
+
~~~
|
|
370
|
+
|
|
371
|
+
The mount host owns authentication and supplies fetch. Module web code owns only
|
|
372
|
+
its domain routes and UI. Public and platform routes are separate transports;
|
|
373
|
+
callers never encode the `/platform` scope into a route string or supply a
|
|
374
|
+
trusted `X-MS-*` application identity header.
|
|
375
|
+
|
|
376
|
+
`context.appId` is informational mount-local data for state and links. Browser
|
|
377
|
+
code must never treat it as trusted request identity or turn it into an
|
|
378
|
+
`X-MS-App-ID` header; the host transport owns authoritative application scope.
|
|
379
|
+
|
|
380
|
+
Send JSON with the same transport-owned serialization used by composed module
|
|
381
|
+
clients:
|
|
382
|
+
|
|
383
|
+
~~~ts
|
|
384
|
+
await api.platform.patch("/settings", {
|
|
385
|
+
json: { sessionLifetimeDays: 30 },
|
|
386
|
+
responseType: "void",
|
|
387
|
+
});
|
|
388
|
+
~~~
|
|
389
|
+
|
|
390
|
+
Create one subpath store per mount so navigation snapshots cannot leak between
|
|
391
|
+
module instances:
|
|
392
|
+
|
|
393
|
+
~~~ts
|
|
394
|
+
import { createModuleSubpathStore } from "@mirrorstack-ai/app-module-client/web";
|
|
395
|
+
|
|
396
|
+
const subpath = createModuleSubpathStore(context.subpath);
|
|
397
|
+
subpath.publish([{ segment: "users", label: "Users" }]);
|
|
398
|
+
// Call subpath.dispose() when this mount is removed.
|
|
399
|
+
~~~
|
|
400
|
+
|
|
401
|
+
The v0.1.0 mount-local text cache remains available for callers that need
|
|
402
|
+
request coalescing and bounded retention without sharing parsed objects between
|
|
403
|
+
consumers:
|
|
404
|
+
|
|
405
|
+
~~~ts
|
|
406
|
+
import { createModuleTextCache } from "@mirrorstack-ai/app-module-client/web";
|
|
407
|
+
|
|
408
|
+
const cache = createModuleTextCache();
|
|
409
|
+
const text = await cache.cachedText("users", (signal) =>
|
|
410
|
+
api.platform.get("/users", { signal, responseType: "text" }),
|
|
411
|
+
);
|
|
412
|
+
const users = JSON.parse(text);
|
|
413
|
+
|
|
414
|
+
// On owner teardown:
|
|
415
|
+
cache.clear();
|
|
416
|
+
~~~
|
|
417
|
+
|
|
418
|
+
`createModuleWebTransport()` also remains exported for v0.1.0 source
|
|
419
|
+
compatibility. New code should use the plural `createModuleWebTransports()`
|
|
420
|
+
API so public and platform routes cannot be confused. Its legacy `appId`
|
|
421
|
+
option is informational only and is never emitted as a trusted browser
|
|
422
|
+
`X-MS-App-ID` header.
|
|
423
|
+
|
|
424
|
+
When mounting a component contributed by another installed module, hosts should
|
|
425
|
+
resolve and pass its authoritative `moduleId`. The optional `moduleSlug` is only
|
|
426
|
+
a routing/display hint when an ID is present; slug-only mounts remain accepted
|
|
427
|
+
for compatibility with older hosts.
|
|
428
|
+
|
|
429
|
+
Use the framework-neutral lifecycle wrapper so each contribution mounts into a
|
|
430
|
+
child created by the target's own document and late asynchronous mounts cannot
|
|
431
|
+
survive their owner:
|
|
432
|
+
|
|
433
|
+
~~~ts
|
|
434
|
+
import { mountModuleComponent } from "@mirrorstack-ai/app-module-client/web";
|
|
435
|
+
|
|
436
|
+
const contribution = mountModuleComponent(target, (componentTarget) =>
|
|
437
|
+
context.modules!.mount({
|
|
438
|
+
moduleId,
|
|
439
|
+
component: "user-badge",
|
|
440
|
+
target: componentTarget,
|
|
441
|
+
props: { userId },
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const unsubscribe = contribution.subscribe(renderAvailability);
|
|
446
|
+
renderAvailability(); // Inspect contribution.getSnapshot() and getError().
|
|
447
|
+
|
|
448
|
+
// On owner teardown:
|
|
449
|
+
unsubscribe();
|
|
450
|
+
contribution.dispose();
|
|
451
|
+
~~~
|
|
452
|
+
|
|
453
|
+
The observable snapshot (`mounting`, `ready`, `unavailable`, or `disposed`) can
|
|
454
|
+
also feed React's `useSyncExternalStore` without moving this lifecycle into the
|
|
455
|
+
React entry point.
|
|
456
|
+
|
|
457
|
+
Component exports can type their validated props and emitted payloads with
|
|
458
|
+
`ModuleComponentMountContext<TProps, TEventPayload>` instead of redeclaring the
|
|
459
|
+
host bridge in every bundle.
|
|
460
|
+
|
|
461
|
+
React modules may opt into the separate adapter. Consumers of this entry point
|
|
462
|
+
must install `react` and `react-dom`; neither is loaded by the root or `./web`
|
|
463
|
+
entry point.
|
|
464
|
+
|
|
465
|
+
~~~tsx
|
|
466
|
+
import {
|
|
467
|
+
mountReactSurface,
|
|
468
|
+
useNow,
|
|
469
|
+
usePlatformUnsavedState,
|
|
470
|
+
} from "@mirrorstack-ai/app-module-client/web/react";
|
|
471
|
+
|
|
472
|
+
function App() {
|
|
473
|
+
const now = useNow(); // Refreshes every 30 seconds; no document/window required.
|
|
474
|
+
usePlatformUnsavedState(context.unsaved, dirty ? unsavedState : null);
|
|
475
|
+
return <time>{new Date(now).toISOString()}</time>;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const dispose = mountReactSurface(target, {
|
|
479
|
+
moduleSlug: "user-core",
|
|
480
|
+
styles: compiledStyles,
|
|
481
|
+
element: <App />,
|
|
482
|
+
dispose: () => runtime.dispose(),
|
|
483
|
+
});
|
|
484
|
+
~~~
|
|
485
|
+
|
|
486
|
+
The adapter registers each exact compiled stylesheet once per document. Mounts
|
|
487
|
+
that share it retain the same style element, which is removed after the final
|
|
488
|
+
mount is cleaned up.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** @internal */
|
|
2
|
+
export declare function assertAppSlug(appSlug: string): void;
|
|
3
|
+
/** Inputs for {@link platformBaseUrl}. */
|
|
4
|
+
export interface PlatformBaseUrlOptions {
|
|
5
|
+
/** Absolute HTTP(S) platform API URL, typically `MIRRORSTACK_API_URL` (`https://api.<org-domain>`). */
|
|
6
|
+
readonly apiUrl: string;
|
|
7
|
+
/**
|
|
8
|
+
* The custom application's slug, typically `MIRRORSTACK_APP_SLUG` — the app
|
|
9
|
+
* slug shown in the console URL (`apps.mirrorstack.ai/apps/<slug>`).
|
|
10
|
+
*/
|
|
11
|
+
readonly appSlug: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Builds the `baseUrl` a custom web app passes to `createAppClient` when it
|
|
15
|
+
* talks to the platform directly.
|
|
16
|
+
*
|
|
17
|
+
* The platform serves a custom app's installed modules at
|
|
18
|
+
* `https://api.<org-domain>/v1/apps/app/<appSlug>/<scope>/<moduleRef>/<path>`,
|
|
19
|
+
* so the app reads `MIRRORSTACK_API_URL` and `MIRRORSTACK_APP_SLUG` from its
|
|
20
|
+
* environment and lets the client append `/<scope>/<moduleRef>/<path>`:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const client = createAppClient({
|
|
24
|
+
* baseUrl: platformBaseUrl({
|
|
25
|
+
* apiUrl: process.env.MIRRORSTACK_API_URL!,
|
|
26
|
+
* appSlug: process.env.MIRRORSTACK_APP_SLUG!,
|
|
27
|
+
* }),
|
|
28
|
+
* modules: { user: userCore() },
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* `apiUrl` must be an absolute HTTP(S) URL without credentials, a query, or a
|
|
33
|
+
* fragment; trailing slashes are removed and any path prefix is preserved.
|
|
34
|
+
* `appSlug` must be a lowercase app slug (`[a-z0-9][a-z0-9-]{0,38}`, the
|
|
35
|
+
* platform's app rule — not the shorter module catalog slug rule). Both are
|
|
36
|
+
* validated before any URL is composed, so a misconfigured environment fails
|
|
37
|
+
* at startup rather than as a 404 on the first request.
|
|
38
|
+
*/
|
|
39
|
+
export declare function platformBaseUrl(options: PlatformBaseUrlOptions): string;
|
package/dist/base-url.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { normalizeBaseUrl } from "./transport.js";
|
|
2
|
+
// The platform's APP slug rule, mirrored from api-platform
|
|
3
|
+
// internal/shared/slugs/slugs.go (`Format`): lowercase ASCII alphanumerics and
|
|
4
|
+
// hyphens, may start with a digit, 1-39 characters. This is distinct from the
|
|
5
|
+
// module catalog slug rule in plugin.ts (`[a-z][a-z0-9-]{0,15}`), which is
|
|
6
|
+
// narrower and applies to `moduleRef`, never to an app slug.
|
|
7
|
+
const APP_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
|
8
|
+
/** @internal */
|
|
9
|
+
export function assertAppSlug(appSlug) {
|
|
10
|
+
if (typeof appSlug !== "string" || !APP_SLUG_PATTERN.test(appSlug)) {
|
|
11
|
+
throw new TypeError("appSlug must be a lowercase app slug matching [a-z0-9][a-z0-9-]{0,38}");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Builds the `baseUrl` a custom web app passes to `createAppClient` when it
|
|
16
|
+
* talks to the platform directly.
|
|
17
|
+
*
|
|
18
|
+
* The platform serves a custom app's installed modules at
|
|
19
|
+
* `https://api.<org-domain>/v1/apps/app/<appSlug>/<scope>/<moduleRef>/<path>`,
|
|
20
|
+
* so the app reads `MIRRORSTACK_API_URL` and `MIRRORSTACK_APP_SLUG` from its
|
|
21
|
+
* environment and lets the client append `/<scope>/<moduleRef>/<path>`:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* const client = createAppClient({
|
|
25
|
+
* baseUrl: platformBaseUrl({
|
|
26
|
+
* apiUrl: process.env.MIRRORSTACK_API_URL!,
|
|
27
|
+
* appSlug: process.env.MIRRORSTACK_APP_SLUG!,
|
|
28
|
+
* }),
|
|
29
|
+
* modules: { user: userCore() },
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* `apiUrl` must be an absolute HTTP(S) URL without credentials, a query, or a
|
|
34
|
+
* fragment; trailing slashes are removed and any path prefix is preserved.
|
|
35
|
+
* `appSlug` must be a lowercase app slug (`[a-z0-9][a-z0-9-]{0,38}`, the
|
|
36
|
+
* platform's app rule — not the shorter module catalog slug rule). Both are
|
|
37
|
+
* validated before any URL is composed, so a misconfigured environment fails
|
|
38
|
+
* at startup rather than as a 404 on the first request.
|
|
39
|
+
*/
|
|
40
|
+
export function platformBaseUrl(options) {
|
|
41
|
+
if (options === null || typeof options !== "object") {
|
|
42
|
+
throw new TypeError("platformBaseUrl options must be an object");
|
|
43
|
+
}
|
|
44
|
+
const { apiUrl, appSlug } = options;
|
|
45
|
+
if (typeof apiUrl !== "string" || !/^https?:\/\//u.test(apiUrl)) {
|
|
46
|
+
throw new TypeError("apiUrl must be an absolute HTTP(S) URL");
|
|
47
|
+
}
|
|
48
|
+
assertAppSlug(appSlug);
|
|
49
|
+
return `${normalizeBaseUrl(apiUrl, "apiUrl")}/v1/apps/app/${appSlug}`;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=base-url.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base-url.js","sourceRoot":"","sources":["../src/base-url.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,2DAA2D;AAC3D,+EAA+E;AAC/E,8EAA8E;AAC9E,2EAA2E;AAC3E,6DAA6D;AAC7D,MAAM,gBAAgB,GAAG,2BAA2B,CAAC;AAErD,gBAAgB;AAChB,MAAM,UAAU,aAAa,CAAC,OAAe;IAC3C,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QACnE,MAAM,IAAI,SAAS,CAAC,uEAAuE,CAAC,CAAC;IAC/F,CAAC;AACH,CAAC;AAaD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,eAAe,CAAC,OAA+B;IAC7D,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QACpD,MAAM,IAAI,SAAS,CAAC,2CAA2C,CAAC,CAAC;IACnE,CAAC;IACD,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IACpC,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QAChE,MAAM,IAAI,SAAS,CAAC,wCAAwC,CAAC,CAAC;IAChE,CAAC;IACD,aAAa,CAAC,OAAO,CAAC,CAAC;IACvB,OAAO,GAAG,gBAAgB,CAAC,MAAM,EAAE,QAAQ,CAAC,gBAAgB,OAAO,EAAE,CAAC;AACxE,CAAC","sourcesContent":["import { normalizeBaseUrl } from \"./transport.js\";\n\n// The platform's APP slug rule, mirrored from api-platform\n// internal/shared/slugs/slugs.go (`Format`): lowercase ASCII alphanumerics and\n// hyphens, may start with a digit, 1-39 characters. This is distinct from the\n// module catalog slug rule in plugin.ts (`[a-z][a-z0-9-]{0,15}`), which is\n// narrower and applies to `moduleRef`, never to an app slug.\nconst APP_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,38}$/;\n\n/** @internal */\nexport function assertAppSlug(appSlug: string): void {\n if (typeof appSlug !== \"string\" || !APP_SLUG_PATTERN.test(appSlug)) {\n throw new TypeError(\"appSlug must be a lowercase app slug matching [a-z0-9][a-z0-9-]{0,38}\");\n }\n}\n\n/** Inputs for {@link platformBaseUrl}. */\nexport interface PlatformBaseUrlOptions {\n /** Absolute HTTP(S) platform API URL, typically `MIRRORSTACK_API_URL` (`https://api.<org-domain>`). */\n readonly apiUrl: string;\n /**\n * The custom application's slug, typically `MIRRORSTACK_APP_SLUG` — the app\n * slug shown in the console URL (`apps.mirrorstack.ai/apps/<slug>`).\n */\n readonly appSlug: string;\n}\n\n/**\n * Builds the `baseUrl` a custom web app passes to `createAppClient` when it\n * talks to the platform directly.\n *\n * The platform serves a custom app's installed modules at\n * `https://api.<org-domain>/v1/apps/app/<appSlug>/<scope>/<moduleRef>/<path>`,\n * so the app reads `MIRRORSTACK_API_URL` and `MIRRORSTACK_APP_SLUG` from its\n * environment and lets the client append `/<scope>/<moduleRef>/<path>`:\n *\n * ```ts\n * const client = createAppClient({\n * baseUrl: platformBaseUrl({\n * apiUrl: process.env.MIRRORSTACK_API_URL!,\n * appSlug: process.env.MIRRORSTACK_APP_SLUG!,\n * }),\n * modules: { user: userCore() },\n * });\n * ```\n *\n * `apiUrl` must be an absolute HTTP(S) URL without credentials, a query, or a\n * fragment; trailing slashes are removed and any path prefix is preserved.\n * `appSlug` must be a lowercase app slug (`[a-z0-9][a-z0-9-]{0,38}`, the\n * platform's app rule — not the shorter module catalog slug rule). Both are\n * validated before any URL is composed, so a misconfigured environment fails\n * at startup rather than as a 404 on the first request.\n */\nexport function platformBaseUrl(options: PlatformBaseUrlOptions): string {\n if (options === null || typeof options !== \"object\") {\n throw new TypeError(\"platformBaseUrl options must be an object\");\n }\n const { apiUrl, appSlug } = options;\n if (typeof apiUrl !== \"string\" || !/^https?:\\/\\//u.test(apiUrl)) {\n throw new TypeError(\"apiUrl must be an absolute HTTP(S) URL\");\n }\n assertAppSlug(appSlug);\n return `${normalizeBaseUrl(apiUrl, \"apiUrl\")}/v1/apps/app/${appSlug}`;\n}\n"]}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ModuleClientPlugin } from "./plugin.js";
|
|
2
|
+
import { type PlatformAuth, type RequestHeaders, type RequestMetadata } from "./transport.js";
|
|
3
|
+
/** A named set of explicitly imported module plugins. */
|
|
4
|
+
export type ModulePluginMap = Readonly<Record<string, ModuleClientPlugin<unknown>>>;
|
|
5
|
+
/** Resolves the API created by one module plugin. */
|
|
6
|
+
export type ModuleApi<TPlugin> = TPlugin extends ModuleClientPlugin<infer TApi> ? TApi : never;
|
|
7
|
+
/** The immutable application client returned by {@link createAppClient}. */
|
|
8
|
+
export interface AppClient<TModules extends ModulePluginMap> {
|
|
9
|
+
/** Typed APIs keyed by the aliases supplied in `modules`. */
|
|
10
|
+
readonly modules: Readonly<{
|
|
11
|
+
[TKey in keyof TModules]: ModuleApi<TModules[TKey]>;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
/** Configuration for {@link createAppClient}. */
|
|
15
|
+
export interface CreateAppClientOptions<TModules extends ModulePluginMap> {
|
|
16
|
+
/**
|
|
17
|
+
* App dispatch root; the client appends `/<scope>/<moduleRef>/<path>`.
|
|
18
|
+
* Use `platformBaseUrl(...)` (`https://api.<org-domain>/v1/apps/app/<appSlug>`)
|
|
19
|
+
* for a direct platform connection, or a same-origin BFF path.
|
|
20
|
+
*/
|
|
21
|
+
readonly baseUrl: string;
|
|
22
|
+
/** Explicit plugin composition; no package is discovered dynamically. */
|
|
23
|
+
readonly modules: TModules;
|
|
24
|
+
/** Fetch implementation, useful for SSR and tests. Defaults to `globalThis.fetch`. */
|
|
25
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
26
|
+
/** Static headers or an async provider invoked for each logical request. */
|
|
27
|
+
readonly headers?: RequestHeaders;
|
|
28
|
+
/**
|
|
29
|
+
* The signed-in member's credential, sent as `Authorization: Bearer` on
|
|
30
|
+
* PUBLIC scope only.
|
|
31
|
+
*
|
|
32
|
+
* 🔴 Use this rather than putting the credential in `headers`. A configured
|
|
33
|
+
* header applies to every scope, and platform scope rejects a configured
|
|
34
|
+
* Authorization outright — so an app with a signed-in member would be unable
|
|
35
|
+
* to call any platform method the moment a module client gained one. The
|
|
36
|
+
* failure is a TypeError at request time, not a compile error, and it is
|
|
37
|
+
* latent until the first platform-scope method exists.
|
|
38
|
+
*/
|
|
39
|
+
readonly memberCredential?: string;
|
|
40
|
+
/** Fetch credentials policy. Defaults to `include`. */
|
|
41
|
+
readonly credentials?: RequestCredentials;
|
|
42
|
+
/** Metadata made available to the header provider on every request. */
|
|
43
|
+
readonly metadata?: RequestMetadata;
|
|
44
|
+
/** Optional access-token lifecycle used only by platform-scope requests. */
|
|
45
|
+
readonly platformAuth?: PlatformAuth;
|
|
46
|
+
/** Maximum bytes parsed from response bodies. Defaults to one mebibyte. */
|
|
47
|
+
readonly maxResponseBytes?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Creates an application client from an explicit object of module plugins.
|
|
51
|
+
*
|
|
52
|
+
* The object keys are local aliases and do not affect routing. A module
|
|
53
|
+
* reference may occur only once so two aliases cannot silently address the
|
|
54
|
+
* same installed module with different expectations.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createAppClient<const TModules extends ModulePluginMap>(options: CreateAppClientOptions<TModules>): AppClient<TModules>;
|