@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +202 -0
  3. package/README.md +488 -0
  4. package/dist/base-url.d.ts +39 -0
  5. package/dist/base-url.js +51 -0
  6. package/dist/base-url.js.map +1 -0
  7. package/dist/client.d.ts +56 -0
  8. package/dist/client.js +85 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/error.d.ts +29 -0
  11. package/dist/error.js +100 -0
  12. package/dist/error.js.map +1 -0
  13. package/dist/index.d.ts +6 -0
  14. package/dist/index.js +6 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/next/auth-routes.d.ts +82 -0
  17. package/dist/next/auth-routes.js +164 -0
  18. package/dist/next/auth-routes.js.map +1 -0
  19. package/dist/next/index.d.ts +12 -0
  20. package/dist/next/index.js +13 -0
  21. package/dist/next/index.js.map +1 -0
  22. package/dist/next/module-proxy-routes.d.ts +81 -0
  23. package/dist/next/module-proxy-routes.js +77 -0
  24. package/dist/next/module-proxy-routes.js.map +1 -0
  25. package/dist/plugin.d.ts +39 -0
  26. package/dist/plugin.js +39 -0
  27. package/dist/plugin.js.map +1 -0
  28. package/dist/response.d.ts +13 -0
  29. package/dist/response.js +80 -0
  30. package/dist/response.js.map +1 -0
  31. package/dist/server/index.d.ts +7 -0
  32. package/dist/server/index.js +8 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/member-sessions.d.ts +80 -0
  35. package/dist/server/member-sessions.js +162 -0
  36. package/dist/server/member-sessions.js.map +1 -0
  37. package/dist/transport.d.ts +98 -0
  38. package/dist/transport.js +299 -0
  39. package/dist/transport.js.map +1 -0
  40. package/dist/web/cache.d.ts +33 -0
  41. package/dist/web/cache.js +130 -0
  42. package/dist/web/cache.js.map +1 -0
  43. package/dist/web/component-mount.d.ts +23 -0
  44. package/dist/web/component-mount.js +111 -0
  45. package/dist/web/component-mount.js.map +1 -0
  46. package/dist/web/index.d.ts +6 -0
  47. package/dist/web/index.js +7 -0
  48. package/dist/web/index.js.map +1 -0
  49. package/dist/web/localized-text.d.ts +10 -0
  50. package/dist/web/localized-text.js +36 -0
  51. package/dist/web/localized-text.js.map +1 -0
  52. package/dist/web/react.d.ts +18 -0
  53. package/dist/web/react.js +111 -0
  54. package/dist/web/react.js.map +1 -0
  55. package/dist/web/runtime.d.ts +60 -0
  56. package/dist/web/runtime.js +73 -0
  57. package/dist/web/runtime.js.map +1 -0
  58. package/dist/web/subpath.d.ts +22 -0
  59. package/dist/web/subpath.js +50 -0
  60. package/dist/web/subpath.js.map +1 -0
  61. package/dist/web/types.d.ts +129 -0
  62. package/dist/web/types.js +2 -0
  63. package/dist/web/types.js.map +1 -0
  64. package/dist/web/use-now.d.ts +7 -0
  65. package/dist/web/use-now.js +22 -0
  66. package/dist/web/use-now.js.map +1 -0
  67. package/dist/web/use-platform-unsaved-state.d.ts +10 -0
  68. package/dist/web/use-platform-unsaved-state.js +35 -0
  69. package/dist/web/use-platform-unsaved-state.js.map +1 -0
  70. 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;
@@ -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"]}
@@ -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>;