@motujs/adapter-next 0.0.0-stage → 0.1.0
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/dist/archipelago.d.ts +63 -0
- package/dist/archipelago.d.ts.map +1 -0
- package/dist/archipelago.js +94 -0
- package/dist/archipelago.js.map +1 -0
- package/dist/contract.d.ts +21 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +47 -0
- package/dist/contract.js.map +1 -0
- package/dist/host-bridge.d.ts +42 -0
- package/dist/host-bridge.d.ts.map +1 -0
- package/dist/host-bridge.js +44 -0
- package/dist/host-bridge.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +41 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +72 -0
- package/dist/server.js.map +1 -0
- package/dist/services.d.ts +24 -0
- package/dist/services.d.ts.map +1 -0
- package/dist/services.js +34 -0
- package/dist/services.js.map +1 -0
- package/package.json +38 -3
- package/verify.mjs +169 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scorbutics
|
|
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.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type ElementSpec } from '@motujs/react';
|
|
3
|
+
import type { AnyArchipelagoConfig, Channel, HostBridge, IslandIsolation, MotuTheme } from '@motujs/core';
|
|
4
|
+
export interface ArchipelagoProps {
|
|
5
|
+
/**
|
|
6
|
+
* The archipelago being mounted (from the project's archipelagos registry).
|
|
7
|
+
*
|
|
8
|
+
* Erased region type: mounting only ROUTES a region — it never reads a bind key — so insisting on the
|
|
9
|
+
* declared shape here would force every composition root to be generic for no checking gained. The
|
|
10
|
+
* checking happens where the archipelago is declared.
|
|
11
|
+
*/
|
|
12
|
+
config: AnyArchipelagoConfig;
|
|
13
|
+
/** The project's element registry — the same one the lagoon uses. */
|
|
14
|
+
elements: ElementSpec[];
|
|
15
|
+
/** Compiled island stylesheet text (the project's shared sheet). Only used by `mount="element"`. */
|
|
16
|
+
css?: string;
|
|
17
|
+
/** Outward seam: navigate/action intents. Build one with `nextHostBridge(useRouter())`. */
|
|
18
|
+
host?: HostBridge;
|
|
19
|
+
/** Initial store contents, so bound islands render meaningfully on first paint. */
|
|
20
|
+
seed?: Record<string, unknown>;
|
|
21
|
+
/** Inbound channels: host signals mirrored into the store. */
|
|
22
|
+
channels?: Channel[];
|
|
23
|
+
/** Default skin. A Next host has no legacy skin to match, so 'motu'. */
|
|
24
|
+
defaultTheme?: MotuTheme;
|
|
25
|
+
/**
|
|
26
|
+
* Region encapsulation. Defaults to 'light' — the opposite of the framework default, and on purpose.
|
|
27
|
+
* A shadow root is right for an ocean whose stylesheet would otherwise bleed into the islands. Here
|
|
28
|
+
* the app's stylesheet is the POINT: islands are the app's own components, styled by the app's own
|
|
29
|
+
* Tailwind, and a shadow root blocks exactly that, rendering them unstyled inside their own page.
|
|
30
|
+
* Only meaningful for `mount="element"`.
|
|
31
|
+
*/
|
|
32
|
+
isolation?: IslandIsolation;
|
|
33
|
+
/** How islands attach. See the note at the top of this file. */
|
|
34
|
+
mount?: 'react' | 'element';
|
|
35
|
+
className?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Place islands yourself with `<Island slot="…">`, anywhere in the page's own markup. Without
|
|
38
|
+
* children every declared slot renders in config order, which is the sensible default for a region
|
|
39
|
+
* that owns its whole area.
|
|
40
|
+
*/
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Render one archipelago into a Next page.
|
|
45
|
+
*
|
|
46
|
+
* ```tsx
|
|
47
|
+
* 'use client';
|
|
48
|
+
* export function Actions() {
|
|
49
|
+
* const router = useRouter();
|
|
50
|
+
* const host = useMemo(() => nextHostBridge(router), [router]);
|
|
51
|
+
* return (
|
|
52
|
+
* <Archipelago config={getArchipelago('actions')!} elements={ELEMENT_REGISTRY} host={host}>
|
|
53
|
+
* <div className="grid gap-6 lg:grid-cols-2">
|
|
54
|
+
* <Island slot="network-stats" />
|
|
55
|
+
* <Island slot="revenue-thanks" />
|
|
56
|
+
* </div>
|
|
57
|
+
* </Archipelago>
|
|
58
|
+
* );
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export declare function Archipelago(props: ArchipelagoProps): import("react").JSX.Element;
|
|
63
|
+
//# sourceMappingURL=archipelago.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"archipelago.d.ts","sourceRoot":"","sources":["../src/archipelago.tsx"],"names":[],"mappings":"AAmBA,OAAO,EAA+B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACpE,OAAO,EAA8C,KAAK,WAAW,EAAwB,MAAM,eAAe,CAAC;AAEnH,OAAO,KAAK,EAAE,oBAAoB,EAAE,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE1G,MAAM,WAAW,gBAAgB;IAC/B;;;;;;OAMG;IACH,MAAM,EAAE,oBAAoB,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,oGAAoG;IACpG,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2FAA2F;IAC3F,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,mFAAmF;IACnF,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;IACrB,wEAAwE;IACxE,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,gEAAgE;IAChE,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAwBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,+BAyBlD"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// Mounting an archipelago inside a Next page.
|
|
4
|
+
//
|
|
5
|
+
// There is no bridge script here and no injected markers: a Next host is already React, so islands
|
|
6
|
+
// render in the page's own tree, in place. What the archipelago still buys — and the reason this is
|
|
7
|
+
// not just an import of the ui components — is the discipline: one declared Store per region,
|
|
8
|
+
// declarative `bind` from store key to island prop, the host-intent seam, and the seam lens's view of
|
|
9
|
+
// all three.
|
|
10
|
+
//
|
|
11
|
+
// `mount` picks HOW islands attach:
|
|
12
|
+
//
|
|
13
|
+
// 'react' (default) each island renders inside this page's React tree. Context, error boundaries
|
|
14
|
+
// and Suspense from the page reach it, and there is one React root for the whole page.
|
|
15
|
+
// 'element' each island mounts as a <motu-island> custom element with its own React root. For the
|
|
16
|
+
// case a React host does not actually own the DOM the island lands in — markup rendered
|
|
17
|
+
// by a CMS, a slot filled imperatively — where there is no tree to join.
|
|
18
|
+
//
|
|
19
|
+
// Isolation is a different axis and is unaffected by this: it decides whether the host's stylesheet
|
|
20
|
+
// reaches the island, not which tree the island renders in.
|
|
21
|
+
import { useEffect, useRef, useState } from 'react';
|
|
22
|
+
import { ArchipelagoProvider, Island, defineMotuApp } from '@motujs/react';
|
|
23
|
+
import { setDefaultIsolation } from '@motujs/core';
|
|
24
|
+
// defineMotuApp registers custom elements, which is global and one-shot per tag: calling it twice for
|
|
25
|
+
// the same archipelago throws on re-registration. React StrictMode double-invokes effects and Next
|
|
26
|
+
// remounts across navigations, so guard by archipelago id.
|
|
27
|
+
const defined = new Set();
|
|
28
|
+
function defineElements(props) {
|
|
29
|
+
if (defined.has(props.config.id))
|
|
30
|
+
return;
|
|
31
|
+
defined.add(props.config.id);
|
|
32
|
+
// Must be set before the elements are defined: <motu-archipelago> reads the default when it
|
|
33
|
+
// connects, and a region that has already attached a shadow root cannot give it back.
|
|
34
|
+
setDefaultIsolation(props.isolation ?? 'light');
|
|
35
|
+
const archipelagos = [
|
|
36
|
+
{ config: props.config, options: { host: props.host, seed: props.seed, channels: props.channels } },
|
|
37
|
+
];
|
|
38
|
+
defineMotuApp({
|
|
39
|
+
elements: props.elements,
|
|
40
|
+
archipelagos,
|
|
41
|
+
css: props.css,
|
|
42
|
+
defaultTheme: props.defaultTheme ?? 'motu',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Render one archipelago into a Next page.
|
|
47
|
+
*
|
|
48
|
+
* ```tsx
|
|
49
|
+
* 'use client';
|
|
50
|
+
* export function Actions() {
|
|
51
|
+
* const router = useRouter();
|
|
52
|
+
* const host = useMemo(() => nextHostBridge(router), [router]);
|
|
53
|
+
* return (
|
|
54
|
+
* <Archipelago config={getArchipelago('actions')!} elements={ELEMENT_REGISTRY} host={host}>
|
|
55
|
+
* <div className="grid gap-6 lg:grid-cols-2">
|
|
56
|
+
* <Island slot="network-stats" />
|
|
57
|
+
* <Island slot="revenue-thanks" />
|
|
58
|
+
* </div>
|
|
59
|
+
* </Archipelago>
|
|
60
|
+
* );
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function Archipelago(props) {
|
|
65
|
+
if (props.mount === 'element')
|
|
66
|
+
return _jsx(ElementArchipelago, { ...props });
|
|
67
|
+
// Without children, every declared slot renders in config order — the sensible default for a region
|
|
68
|
+
// that owns its whole area. The annotation keeps the union from widening across the React 18/19
|
|
69
|
+
// type boundary this package can be compiled over.
|
|
70
|
+
const placed = props.children ?? (_jsx(_Fragment, { children: props.config.islands.map((i) => (_jsx(Island, { slot: i.slot }, i.slot))) }));
|
|
71
|
+
return (_jsx(ArchipelagoProvider, { config: props.config, elements: props.elements, host: props.host, seed: props.seed, channels: props.channels, children: placed }));
|
|
72
|
+
}
|
|
73
|
+
/** The custom-element path: one React root per island, for a host that does not own this DOM. */
|
|
74
|
+
function ElementArchipelago(props) {
|
|
75
|
+
const ref = useRef(null);
|
|
76
|
+
// Custom elements only exist in the browser; defining during render would run on the server too.
|
|
77
|
+
const [ready, setReady] = useState(false);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
defineElements(props);
|
|
80
|
+
setReady(true);
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [props.config.id]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const mount = ref.current;
|
|
85
|
+
if (!ready || !mount)
|
|
86
|
+
return;
|
|
87
|
+
const el = document.createElement('motu-archipelago');
|
|
88
|
+
el.setAttribute('name', props.config.id);
|
|
89
|
+
mount.appendChild(el);
|
|
90
|
+
return () => el.remove();
|
|
91
|
+
}, [ready, props.config.id]);
|
|
92
|
+
return _jsx("div", { ref: ref, className: props.className });
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=archipelago.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"archipelago.js","sourceRoot":"","sources":["../src/archipelago.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MotuServiceMap } from './services.js';
|
|
2
|
+
/** The client's shape: the server's service map, with every method reachable as-is. */
|
|
3
|
+
export type Contract<S extends MotuServiceMap> = {
|
|
4
|
+
readonly [Service in keyof S]: {
|
|
5
|
+
readonly [Method in keyof S[Service]]: S[Service][Method];
|
|
6
|
+
};
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Build the typed contract client. Pass the server's service map type; nothing is imported from the
|
|
10
|
+
* server at runtime (the type argument is erased), so this stays safe in a client component.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import type { AppServices } from '@/motu/server/services' // type-only: erased at build
|
|
14
|
+
* export const contract = createContract<AppServices>()
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The proxy is lazy and shallow — two levels, resolved on access — so adding a service or method on
|
|
18
|
+
* the server needs no change here at all.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createContract<S extends MotuServiceMap>(): Contract<S>;
|
|
21
|
+
//# sourceMappingURL=contract.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,uFAAuF;AACvF,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,cAAc,IAAI;IAC/C,QAAQ,EAAE,OAAO,IAAI,MAAM,CAAC,GAAG;QAC7B,QAAQ,EAAE,MAAM,IAAI,MAAM,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;KAC1D;CACF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,cAAc,KAAK,QAAQ,CAAC,CAAC,CAAC,CAqBtE"}
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The client half of the contract seam: a typed façade over the one `call()` seam in @motujs/runtime.
|
|
2
|
+
//
|
|
3
|
+
// This is what `@motujs/contract` is on the Jakarta side, minus the generator. The service map's TYPE
|
|
4
|
+
// is the contract, so `contract.directory.getSectors()` carries the real signature of the function
|
|
5
|
+
// the route handler will run, and a change to that function fails `tsc` here — no regeneration, no
|
|
6
|
+
// window where the two disagree.
|
|
7
|
+
//
|
|
8
|
+
// Everything goes through `call()`, which is why an island using this still works in the lagoon: the
|
|
9
|
+
// lagoon configures MockTransport instead of HttpTransport and the same component replays fixtures
|
|
10
|
+
// with no backend at all.
|
|
11
|
+
import { call } from '@motujs/runtime';
|
|
12
|
+
/**
|
|
13
|
+
* Build the typed contract client. Pass the server's service map type; nothing is imported from the
|
|
14
|
+
* server at runtime (the type argument is erased), so this stays safe in a client component.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import type { AppServices } from '@/motu/server/services' // type-only: erased at build
|
|
18
|
+
* export const contract = createContract<AppServices>()
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The proxy is lazy and shallow — two levels, resolved on access — so adding a service or method on
|
|
22
|
+
* the server needs no change here at all.
|
|
23
|
+
*/
|
|
24
|
+
export function createContract() {
|
|
25
|
+
const services = new Map();
|
|
26
|
+
return new Proxy(Object.create(null), {
|
|
27
|
+
get(_target, service) {
|
|
28
|
+
// A bare property read (a bundler probing for `then`, devtools inspecting) must not be
|
|
29
|
+
// mistaken for a service name.
|
|
30
|
+
if (typeof service !== 'string')
|
|
31
|
+
return undefined;
|
|
32
|
+
const cached = services.get(service);
|
|
33
|
+
if (cached)
|
|
34
|
+
return cached;
|
|
35
|
+
const methods = new Proxy(Object.create(null), {
|
|
36
|
+
get(_t, method) {
|
|
37
|
+
if (typeof method !== 'string')
|
|
38
|
+
return undefined;
|
|
39
|
+
return (...args) => call(service, method, args);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
services.set(service, methods);
|
|
43
|
+
return methods;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=contract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,EAAE;AACF,sGAAsG;AACtG,mGAAmG;AACnG,mGAAmG;AACnG,iCAAiC;AACjC,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,0BAA0B;AAC1B,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAUvC;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,cAAc;IAC5B,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAmB,CAAC;IAE5C,OAAO,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAgB,EAAE;QACnD,GAAG,CAAC,OAAO,EAAE,OAAwB;YACnC,uFAAuF;YACvF,+BAA+B;YAC/B,IAAI,OAAO,OAAO,KAAK,QAAQ;gBAAE,OAAO,SAAS,CAAC;YAClD,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YACrC,IAAI,MAAM;gBAAE,OAAO,MAAM,CAAC;YAE1B,MAAM,OAAO,GAAG,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE;gBAC7C,GAAG,CAAC,EAAE,EAAE,MAAuB;oBAC7B,IAAI,OAAO,MAAM,KAAK,QAAQ;wBAAE,OAAO,SAAS,CAAC;oBACjD,OAAO,CAAC,GAAG,IAAe,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC;gBAC7D,CAAC;aACF,CAAC,CAAC;YACH,QAAQ,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC/B,OAAO,OAAO,CAAC;QACjB,CAAC;KACF,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { HostBridge } from '@motujs/core';
|
|
2
|
+
/** The slice of Next's App Router this bridge needs — structural, so it accepts the real
|
|
3
|
+
* `useRouter()` return value without importing next/navigation into the framework. */
|
|
4
|
+
export interface NextRouterLike {
|
|
5
|
+
push(href: string): void;
|
|
6
|
+
replace(href: string): void;
|
|
7
|
+
back(): void;
|
|
8
|
+
}
|
|
9
|
+
export interface NextHostBridgeOptions {
|
|
10
|
+
/** Use `router.replace` instead of `router.push` (navigation that should not stack history). */
|
|
11
|
+
replace?: boolean;
|
|
12
|
+
/** Event name dispatched on `document` for outward actions. Default 'motu:action'. */
|
|
13
|
+
actionEvent?: string;
|
|
14
|
+
/** Handle an action inline instead of (not as well as) dispatching the DOM event. */
|
|
15
|
+
onAction?: (name: string, detail: unknown) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a HostBridge backed by the Next App Router.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* 'use client';
|
|
22
|
+
* const router = useRouter();
|
|
23
|
+
* const host = useMemo(() => nextHostBridge(router), [router]);
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Actions surface as a DOM CustomEvent so host code can listen without the island knowing who is
|
|
27
|
+
* listening — the same shape the AngularJS adapter uses, so an island's outward contract does not
|
|
28
|
+
* change when the ocean recedes.
|
|
29
|
+
*/
|
|
30
|
+
export declare function nextHostBridge(router: NextRouterLike, opts?: NextHostBridgeOptions): HostBridge;
|
|
31
|
+
/**
|
|
32
|
+
* A HostBridge for contexts with no router — server rendering, tests, or a host that has not wired
|
|
33
|
+
* one yet. Navigation intents are collected rather than performed, so nothing silently no-ops:
|
|
34
|
+
* assert on `intents` in a test, or read it while wiring a page up.
|
|
35
|
+
*/
|
|
36
|
+
export declare function collectingHostBridge(): HostBridge & {
|
|
37
|
+
intents: {
|
|
38
|
+
kind: string;
|
|
39
|
+
value: unknown;
|
|
40
|
+
}[];
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=host-bridge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-bridge.d.ts","sourceRoot":"","sources":["../src/host-bridge.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C;uFACuF;AACvF,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,IAAI,IAAI,IAAI,CAAC;CACd;AAED,MAAM,WAAW,qBAAqB;IACpC,gGAAgG;IAChG,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CACpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,GAAE,qBAA0B,GAAG,UAAU,CAcnG;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,UAAU,GAAG;IAAE,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,EAAE,CAAA;CAAE,CAOnG"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a HostBridge backed by the Next App Router.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* 'use client';
|
|
6
|
+
* const router = useRouter();
|
|
7
|
+
* const host = useMemo(() => nextHostBridge(router), [router]);
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Actions surface as a DOM CustomEvent so host code can listen without the island knowing who is
|
|
11
|
+
* listening — the same shape the AngularJS adapter uses, so an island's outward contract does not
|
|
12
|
+
* change when the ocean recedes.
|
|
13
|
+
*/
|
|
14
|
+
export function nextHostBridge(router, opts = {}) {
|
|
15
|
+
const actionEvent = opts.actionEvent ?? 'motu:action';
|
|
16
|
+
return {
|
|
17
|
+
navigate(path) {
|
|
18
|
+
if (opts.replace)
|
|
19
|
+
router.replace(path);
|
|
20
|
+
else
|
|
21
|
+
router.push(path);
|
|
22
|
+
},
|
|
23
|
+
action(name, detail) {
|
|
24
|
+
opts.onAction?.(name, detail);
|
|
25
|
+
if (typeof document !== 'undefined') {
|
|
26
|
+
document.dispatchEvent(new CustomEvent(actionEvent, { detail: { name, detail }, bubbles: true }));
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A HostBridge for contexts with no router — server rendering, tests, or a host that has not wired
|
|
33
|
+
* one yet. Navigation intents are collected rather than performed, so nothing silently no-ops:
|
|
34
|
+
* assert on `intents` in a test, or read it while wiring a page up.
|
|
35
|
+
*/
|
|
36
|
+
export function collectingHostBridge() {
|
|
37
|
+
const intents = [];
|
|
38
|
+
return {
|
|
39
|
+
intents,
|
|
40
|
+
navigate: (path) => void intents.push({ kind: 'navigate', value: path }),
|
|
41
|
+
action: (name, detail) => void intents.push({ kind: name, value: detail }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=host-bridge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-bridge.js","sourceRoot":"","sources":["../src/host-bridge.ts"],"names":[],"mappings":"AA6BA;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,cAAc,CAAC,MAAsB,EAAE,OAA8B,EAAE;IACrF,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,aAAa,CAAC;IACtD,OAAO;QACL,QAAQ,CAAC,IAAY;YACnB,IAAI,IAAI,CAAC,OAAO;gBAAE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;;gBAClC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzB,CAAC;QACD,MAAM,CAAC,IAAY,EAAE,MAAe;YAClC,IAAI,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAC9B,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;gBACpC,QAAQ,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;YACpG,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,OAAO,GAAuC,EAAE,CAAC;IACvD,OAAO;QACL,OAAO;QACP,QAAQ,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;QACxE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;KAC3E,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { nextHostBridge, collectingHostBridge } from './host-bridge.js';
|
|
2
|
+
export type { NextRouterLike, NextHostBridgeOptions } from './host-bridge.js';
|
|
3
|
+
export { Archipelago } from './archipelago.js';
|
|
4
|
+
export { createRegion } from '@motujs/react';
|
|
5
|
+
export type { CreateRegionOptions, RegionBinding } from '@motujs/react';
|
|
6
|
+
export type { ArchipelagoProps } from './archipelago.js';
|
|
7
|
+
export { Island, useMotuStore } from '@motujs/react';
|
|
8
|
+
export type { IslandProps } from '@motujs/react';
|
|
9
|
+
export { defineServices } from './services.js';
|
|
10
|
+
export type { MotuServiceMap, MotuMethod } from './services.js';
|
|
11
|
+
export { createContract } from './contract.js';
|
|
12
|
+
export type { Contract } from './contract.js';
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrE,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAC3E,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7C,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACxE,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AACrD,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7D,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @motujs/adapter-next — the Next.js host adapter.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately small. The AngularJS adapter is large because AngularJS is a foreign framework: it needs
|
|
4
|
+
// channels to mirror $http/$scope into the store, and a custom-element definer to cross the boundary.
|
|
5
|
+
// A Next host is already React, so neither applies — an island mounts directly. What remains is the
|
|
6
|
+
// part that is genuinely host-specific: turning outward intents into App Router navigation, declaring
|
|
7
|
+
// the browser-callable surface, and (in ./verify, loaded by `motu island verify`) policing the RSC
|
|
8
|
+
// boundary an island must stay inside.
|
|
9
|
+
//
|
|
10
|
+
// The server dispatcher lives behind ./server so a client bundle can never pull it in by accident.
|
|
11
|
+
export { nextHostBridge, collectingHostBridge } from './host-bridge.js';
|
|
12
|
+
export { Archipelago } from './archipelago.js';
|
|
13
|
+
// Re-exported so a Next host has one import site for the region surface.
|
|
14
|
+
export { createRegion } from '@motujs/react';
|
|
15
|
+
// Re-exported so a page places islands without also importing @motujs/react directly.
|
|
16
|
+
export { Island, useMotuStore } from '@motujs/react';
|
|
17
|
+
export { defineServices } from './services.js';
|
|
18
|
+
export { createContract } from './contract.js';
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,EAAE;AACF,wGAAwG;AACxG,sGAAsG;AACtG,oGAAoG;AACpG,sGAAsG;AACtG,mGAAmG;AACnG,uCAAuC;AACvC,EAAE;AACF,mGAAmG;AACnG,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAErE,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,yEAAyE;AACzE,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG7C,sFAAsF;AACtF,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAE5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { MotuServiceMap } from './services.js';
|
|
2
|
+
export interface MotuRouteOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Coarse gate run before dispatch. Return false (or throw) to refuse. This is a cheap early exit,
|
|
5
|
+
* NOT the authorization boundary — that stays with the app's own data access rules.
|
|
6
|
+
*/
|
|
7
|
+
authorize?: (req: Request, call: {
|
|
8
|
+
service: string;
|
|
9
|
+
method: string;
|
|
10
|
+
}) => boolean | Promise<boolean>;
|
|
11
|
+
/** Map a thrown error to a status. Default 500. */
|
|
12
|
+
statusForError?: (err: unknown) => number;
|
|
13
|
+
/** Called for every failure, so the host can log through its own reporting. */
|
|
14
|
+
onError?: (err: unknown, call: {
|
|
15
|
+
service: string;
|
|
16
|
+
method: string;
|
|
17
|
+
}) => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the POST handler for `app/api/motu/[...call]/route.ts`.
|
|
21
|
+
*
|
|
22
|
+
* The URL shape (`<base>/<service>/<method>`, args as a JSON array body) is HttpTransport's, so an
|
|
23
|
+
* island needs no Next-specific transport: point `configure(new HttpTransport('/api/motu'))` at this
|
|
24
|
+
* and the same component works against the real app here and against MockTransport in the lagoon.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* // app/api/motu/[...call]/route.ts
|
|
28
|
+
* import { createMotuRoute } from '@motujs/adapter-next/server'
|
|
29
|
+
* import { services } from '@/motu/server/services'
|
|
30
|
+
* export const { POST } = createMotuRoute(services)
|
|
31
|
+
* export const dynamic = 'force-dynamic'
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function createMotuRoute(services: MotuServiceMap, opts?: MotuRouteOptions): {
|
|
35
|
+
POST: (req: Request, ctx: {
|
|
36
|
+
params: Promise<{
|
|
37
|
+
call?: string[];
|
|
38
|
+
}>;
|
|
39
|
+
}) => Promise<Response>;
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACpG,mDAAmD;IACnD,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;IAC1C,+EAA+E;IAC/E,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CAC7E;AAOD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,cAAc,EAAE,IAAI,GAAE,gBAAqB;gBAC1D,OAAO,OAAO;QAAE,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;SAAE,CAAC,CAAA;KAAE;EA+ChF"}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Unknown service/method is indistinguishable from an unknown URL: deny-by-default leaks nothing. */
|
|
2
|
+
function notFound() {
|
|
3
|
+
return Response.json({ error: 'not found' }, { status: 404 });
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Build the POST handler for `app/api/motu/[...call]/route.ts`.
|
|
7
|
+
*
|
|
8
|
+
* The URL shape (`<base>/<service>/<method>`, args as a JSON array body) is HttpTransport's, so an
|
|
9
|
+
* island needs no Next-specific transport: point `configure(new HttpTransport('/api/motu'))` at this
|
|
10
|
+
* and the same component works against the real app here and against MockTransport in the lagoon.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // app/api/motu/[...call]/route.ts
|
|
14
|
+
* import { createMotuRoute } from '@motujs/adapter-next/server'
|
|
15
|
+
* import { services } from '@/motu/server/services'
|
|
16
|
+
* export const { POST } = createMotuRoute(services)
|
|
17
|
+
* export const dynamic = 'force-dynamic'
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function createMotuRoute(services, opts = {}) {
|
|
21
|
+
async function POST(req, ctx) {
|
|
22
|
+
const segments = (await ctx.params)?.call ?? [];
|
|
23
|
+
if (segments.length !== 2)
|
|
24
|
+
return notFound();
|
|
25
|
+
const [service, method] = segments;
|
|
26
|
+
// Own-property lookups only: a plain `services[service]` would happily resolve 'constructor' or
|
|
27
|
+
// '__proto__' and hand back something that is not an exposed method at all.
|
|
28
|
+
if (!Object.prototype.hasOwnProperty.call(services, service))
|
|
29
|
+
return notFound();
|
|
30
|
+
const impl = services[service];
|
|
31
|
+
if (!impl || !Object.prototype.hasOwnProperty.call(impl, method))
|
|
32
|
+
return notFound();
|
|
33
|
+
const fn = impl[method];
|
|
34
|
+
if (typeof fn !== 'function')
|
|
35
|
+
return notFound();
|
|
36
|
+
if (opts.authorize) {
|
|
37
|
+
let allowed = false;
|
|
38
|
+
try {
|
|
39
|
+
allowed = await opts.authorize(req, { service, method });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
allowed = false;
|
|
43
|
+
}
|
|
44
|
+
// 401, not 403: the caller is unauthenticated. Existence is already established by this point.
|
|
45
|
+
if (!allowed)
|
|
46
|
+
return Response.json({ error: 'unauthorized' }, { status: 401 });
|
|
47
|
+
}
|
|
48
|
+
let args;
|
|
49
|
+
try {
|
|
50
|
+
args = await req.json();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return Response.json({ error: 'body must be a JSON array of arguments' }, { status: 400 });
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(args)) {
|
|
56
|
+
return Response.json({ error: 'body must be a JSON array of arguments' }, { status: 400 });
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const result = await fn(...args);
|
|
60
|
+
// `undefined` is not JSON; a void method should still answer 200 with a body fetch can parse.
|
|
61
|
+
return Response.json(result ?? null);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
opts.onError?.(err, { service, method });
|
|
65
|
+
const status = opts.statusForError?.(err) ?? 500;
|
|
66
|
+
// The message is the app's own; do not leak a stack.
|
|
67
|
+
return Response.json({ error: err instanceof Error ? err.message : 'error' }, { status });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { POST };
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A browser-callable method. Args and result cross the wire as JSON, so both must be serialisable. */
|
|
2
|
+
export type MotuMethod = (...args: never[]) => Promise<unknown>;
|
|
3
|
+
/** The callable surface: service name -> method name -> implementation. */
|
|
4
|
+
export type MotuServiceMap = Record<string, Record<string, MotuMethod>>;
|
|
5
|
+
/**
|
|
6
|
+
* Declare the browser-callable surface.
|
|
7
|
+
*
|
|
8
|
+
* Identity at runtime; its job is to fix the type so `typeof services` can be handed to
|
|
9
|
+
* `createContract` on the client. Keep the returned type — do not widen it to MotuServiceMap, or the
|
|
10
|
+
* contract loses every signature.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* export const services = defineServices({
|
|
14
|
+
* directory: {
|
|
15
|
+
* // Thin wrappers the APP owns: they build the request-scoped client, so the app's own
|
|
16
|
+
* // row-level security decides what this user may read. motu never sees a credential.
|
|
17
|
+
* async getSectors() { return getDirectorySectors(await createSupabaseServerClient()) },
|
|
18
|
+
* },
|
|
19
|
+
* })
|
|
20
|
+
* export type AppServices = typeof services
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function defineServices<const S extends MotuServiceMap>(services: S): S;
|
|
24
|
+
//# sourceMappingURL=services.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../src/services.ts"],"names":[],"mappings":"AAaA,uGAAuG;AACvG,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhE,2EAA2E;AAC3E,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC;AAExE;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAAC,KAAK,CAAC,CAAC,SAAS,cAAc,EAAE,QAAQ,EAAE,CAAC,GAAG,CAAC,CAE7E"}
|
package/dist/services.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The contract seam for a Next host — the analogue of `@BrowserCallable` + `MotuEndpoint` on the
|
|
2
|
+
// Jakarta side, and the piece that lets an island's server I/O be replayed by MockTransport.
|
|
3
|
+
//
|
|
4
|
+
// The Java seam needs an annotation processor because it has to DISCOVER which methods are callable
|
|
5
|
+
// across a whole compiled codebase. TypeScript needs no such thing: the callable surface is an object
|
|
6
|
+
// literal the app writes, and its type IS the contract. That is strictly better than codegen here —
|
|
7
|
+
// there is no regeneration step to forget, and a signature change fails `tsc` at the call site
|
|
8
|
+
// immediately rather than after someone re-runs a generator.
|
|
9
|
+
//
|
|
10
|
+
// Deny-by-default is structural rather than enforced: a method is reachable only if it appears in
|
|
11
|
+
// this map. Nothing else in the app is exposed, and there is no pattern, prefix or convention that
|
|
12
|
+
// could accidentally widen the surface.
|
|
13
|
+
/**
|
|
14
|
+
* Declare the browser-callable surface.
|
|
15
|
+
*
|
|
16
|
+
* Identity at runtime; its job is to fix the type so `typeof services` can be handed to
|
|
17
|
+
* `createContract` on the client. Keep the returned type — do not widen it to MotuServiceMap, or the
|
|
18
|
+
* contract loses every signature.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* export const services = defineServices({
|
|
22
|
+
* directory: {
|
|
23
|
+
* // Thin wrappers the APP owns: they build the request-scoped client, so the app's own
|
|
24
|
+
* // row-level security decides what this user may read. motu never sees a credential.
|
|
25
|
+
* async getSectors() { return getDirectorySectors(await createSupabaseServerClient()) },
|
|
26
|
+
* },
|
|
27
|
+
* })
|
|
28
|
+
* export type AppServices = typeof services
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function defineServices(services) {
|
|
32
|
+
return services;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=services.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"services.js","sourceRoot":"","sources":["../src/services.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,6FAA6F;AAC7F,EAAE;AACF,oGAAoG;AACpG,sGAAsG;AACtG,oGAAoG;AACpG,+FAA+F;AAC/F,6DAA6D;AAC7D,EAAE;AACF,kGAAkG;AAClG,mGAAmG;AACnG,wCAAwC;AAQxC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,cAAc,CAAiC,QAAW;IACxE,OAAO,QAAQ,CAAC;AAClB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motujs/adapter-next",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/Scorbutics/motu.git",
|
|
8
|
+
"directory": "packages/adapters/next"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Scorbutics/motu#readme",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./server": {
|
|
20
|
+
"types": "./dist/server.d.ts",
|
|
21
|
+
"default": "./dist/server.js"
|
|
22
|
+
},
|
|
23
|
+
"./verify": "./verify.mjs"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@motujs/core": "0.1.0",
|
|
27
|
+
"@motujs/react": "0.1.0",
|
|
28
|
+
"@motujs/runtime": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"next": ">=14",
|
|
32
|
+
"react": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"verify.mjs"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
6
41
|
}
|
package/verify.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Adapter-owned verify layer for Next.js islands. Core `motu island verify` runs the framework-neutral
|
|
2
|
+
// static/config/runtime checks; it cannot judge the one coupling unique to this host — the RSC
|
|
3
|
+
// boundary.
|
|
4
|
+
//
|
|
5
|
+
// Why this is the Next analogue of the AngularJS adapter's host-scope check: both police the ONE way an
|
|
6
|
+
// island can silently bind itself to its host. On AngularJS that is reaching up the scope chain. On
|
|
7
|
+
// Next it is reaching across the server/client split. An island that imports `next/headers`, or awaits
|
|
8
|
+
// a server action, or omits 'use client', is a component only Next can render — and the lagoon is a
|
|
9
|
+
// plain Vite SPA with no Next runtime at all. It would pass every static check and then fail to mount,
|
|
10
|
+
// or worse, mount only in the host and never in the loop. The loop is the product; this keeps islands
|
|
11
|
+
// inside it.
|
|
12
|
+
//
|
|
13
|
+
// Operates on the source text + the structured coupling the CLI extracts from element.ts via AST (no
|
|
14
|
+
// ts-morph dependency here — the CLI owns parsing, the adapter owns semantics). Discovery is by package
|
|
15
|
+
// export (`@motujs/adapter-next/verify`), resolved from the adapter the island actually imports.
|
|
16
|
+
|
|
17
|
+
/** Modules that only exist on the server. Importing one makes the component unmountable in the lagoon. */
|
|
18
|
+
const SERVER_ONLY = [
|
|
19
|
+
'server-only',
|
|
20
|
+
'next/headers',
|
|
21
|
+
'next/server',
|
|
22
|
+
'next/cache',
|
|
23
|
+
'next/og',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Next modules an island may use — the lagoon stubs exactly these (see the scaffolded next-stubs). */
|
|
27
|
+
const STUBBED = ['next/link', 'next/image', 'next/navigation'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{ source?: string, coupling?: { serverActions?: boolean } }} input
|
|
31
|
+
* source — the ui component's source text (the CLI reads the file; the adapter judges it)
|
|
32
|
+
* coupling — structured `contract.coupling` from element.ts
|
|
33
|
+
* @returns {{ level: 'error'|'warn'|'ok', check: string, msg: string }[]}
|
|
34
|
+
*/
|
|
35
|
+
export function checkCoupling({ source, coupling, graph } = {}) {
|
|
36
|
+
const findings = [];
|
|
37
|
+
if (typeof source !== 'string') return findings;
|
|
38
|
+
|
|
39
|
+
// Strip comments so a rule is not tripped by prose ABOUT the rule (this file would fail itself).
|
|
40
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
41
|
+
|
|
42
|
+
// Type-only imports are erased at build, so they cannot drag server code into a client bundle —
|
|
43
|
+
// and importing the SERVER'S service-map type is exactly how a typed contract is declared. Judging
|
|
44
|
+
// them as runtime imports would make the correct pattern unusable.
|
|
45
|
+
const runtimeCode = code.replace(/^\s*import\s+type\s[\s\S]*?from\s*['"][^'"]+['"];?/gm, '');
|
|
46
|
+
const imports = [...runtimeCode.matchAll(/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
47
|
+
|
|
48
|
+
// 1. Server-only imports — the island could never mount in the lagoon.
|
|
49
|
+
const serverImports = imports.filter((s) => SERVER_ONLY.some((m) => s === m || s.startsWith(m + '/')));
|
|
50
|
+
if (serverImports.length) {
|
|
51
|
+
for (const s of serverImports) {
|
|
52
|
+
findings.push({
|
|
53
|
+
level: 'error',
|
|
54
|
+
check: 'rsc-boundary',
|
|
55
|
+
msg: `imports server-only '${s}' — an island must mount in the lagoon, which has no Next runtime`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
// THE SAME QUESTION, ASKED OF THE WHOLE BUNDLE. The island's own file was never where this
|
|
60
|
+
// boundary lived: a Next app puts `'use server'` in an actions module and `server-only` in the
|
|
61
|
+
// lib beneath it, so an island four ordinary hops away from either still cannot be bundled for a
|
|
62
|
+
// Vite lagoon. Reported with the CHAIN, because "this island cannot mount" is useless without
|
|
63
|
+
// the hop that made it true — the failure it replaces was an unattributable rollup error.
|
|
64
|
+
const reached = [];
|
|
65
|
+
for (const node of graph ?? []) {
|
|
66
|
+
if (node.truncated) {
|
|
67
|
+
findings.push({
|
|
68
|
+
level: 'warn',
|
|
69
|
+
check: 'rsc-boundary',
|
|
70
|
+
msg: "the island's import graph was larger than this check walks, so the part beyond the cap is unexamined",
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const hop = String(node.source ?? '')
|
|
75
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
76
|
+
.replace(/^\s*\/\/.*$/gm, '')
|
|
77
|
+
.replace(/^\s*import\s+type\s[\s\S]*?from\s*['"][^'"]+['"];?/gm, '');
|
|
78
|
+
const bad = [...hop.matchAll(/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g)]
|
|
79
|
+
.map((m) => m[1])
|
|
80
|
+
.filter((s) => SERVER_ONLY.some((m) => s === m || s.startsWith(m + '/')));
|
|
81
|
+
const action = /^\s*['"]use server['"]/m.test(hop);
|
|
82
|
+
if (bad.length || action) reached.push({ file: node.file, why: bad.length ? `imports server-only '${bad[0]}'` : "is a 'use server' module" });
|
|
83
|
+
}
|
|
84
|
+
if (reached.length) {
|
|
85
|
+
// ONE ROOT CAUSE, ONE FINDING. This emitted a line per offending module (three, then "…and N
|
|
86
|
+
// more"), which read as four separate problems and was counted as four every time the report
|
|
87
|
+
// was re-run. Measured across three cold-start benches: 50% of every finding an agent read in
|
|
88
|
+
// the `motu check` loop was a repeat of one already seen, and in the worst arm this single
|
|
89
|
+
// unfixable coupling accounted for 28 of the 48 findings read — the same four lines, seven
|
|
90
|
+
// times. The modules are evidence for one boundary violation, so they belong in one finding.
|
|
91
|
+
const [first, ...rest] = reached;
|
|
92
|
+
const more = rest.length
|
|
93
|
+
? ` Also via ${rest.slice(0, 2).map((r) => r.file).join(', ')}${rest.length > 2 ? ` and ${rest.length - 2} other module(s)` : ''}.`
|
|
94
|
+
: '';
|
|
95
|
+
findings.push({
|
|
96
|
+
level: 'error',
|
|
97
|
+
check: 'rsc-boundary',
|
|
98
|
+
msg:
|
|
99
|
+
`reaches ${first.file}, which ${first.why} — the island itself is clean, but everything it ` +
|
|
100
|
+
`imports is in the same bundle, and the lagoon has no Next runtime to strip it.${more} ` +
|
|
101
|
+
`This is the APPLICATION's own coupling: it does not change until that import chain does.`,
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
findings.push({
|
|
105
|
+
level: 'ok',
|
|
106
|
+
check: 'rsc-boundary',
|
|
107
|
+
msg: `no server-only imports${graph?.length ? ` (island + ${graph.length} reachable module(s))` : ''}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. 'use client'. An island always REACHES the browser — it mounts inside a custom element, which
|
|
113
|
+
// only ever runs client-side — so the directive is not what makes it work today. It is what keeps
|
|
114
|
+
// it working on the way out: motu's exit path is dropping the wrapper and importing the ui
|
|
115
|
+
// component directly, and at that moment a hook-using component with no directive breaks the
|
|
116
|
+
// first server page that renders it. So: an error once the component actually uses hooks (the
|
|
117
|
+
// break is real and mechanical), a warning otherwise (a pure projection is safe either way).
|
|
118
|
+
const hasUseClient = /^\s*['"]use client['"]/.test(code);
|
|
119
|
+
const usesHooks = /\buse(State|Effect|Reducer|Ref|Callback|Memo|Context|LayoutEffect|Transition|Optimistic)\s*\(/.test(code);
|
|
120
|
+
if (hasUseClient) {
|
|
121
|
+
findings.push({ level: 'ok', check: 'use-client', msg: "declares 'use client'" });
|
|
122
|
+
} else if (usesHooks) {
|
|
123
|
+
findings.push({
|
|
124
|
+
level: 'error',
|
|
125
|
+
check: 'use-client',
|
|
126
|
+
msg: "uses hooks without 'use client' — mounts fine as an island, but breaks the moment the component is imported directly (the exit path)",
|
|
127
|
+
});
|
|
128
|
+
} else {
|
|
129
|
+
findings.push({
|
|
130
|
+
level: 'warn',
|
|
131
|
+
check: 'use-client',
|
|
132
|
+
msg: "no 'use client' — safe while it stays a pure projection; add it before the component grows state",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 3. Server actions. A 'use server' function is an RPC the lagoon cannot serve; server I/O belongs
|
|
137
|
+
// on the contract seam, which MockTransport can stand in for.
|
|
138
|
+
if (/['"]use server['"]/.test(code)) {
|
|
139
|
+
findings.push({
|
|
140
|
+
level: 'error',
|
|
141
|
+
check: 'rsc-boundary',
|
|
142
|
+
msg: "contains a 'use server' action — server I/O goes through the contract seam, not an RPC the lagoon cannot replay",
|
|
143
|
+
});
|
|
144
|
+
} else if (coupling?.serverActions) {
|
|
145
|
+
findings.push({
|
|
146
|
+
level: 'warn',
|
|
147
|
+
check: 'rsc-boundary',
|
|
148
|
+
msg: 'declares serverActions coupling — the lagoon cannot replay these; prefer the contract seam',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4. Stubbed Next modules, REPORTED ON THE OK LINE rather than as their own standing warning.
|
|
153
|
+
//
|
|
154
|
+
// `next-stubs` used to be a warning of its own, fired whenever the island imported one of these —
|
|
155
|
+
// legitimate imports, with no state in which the warning could be cleared except not using them.
|
|
156
|
+
// The information is worth having (what the lagoon renders for them is inert, so an island leaning
|
|
157
|
+
// on their real behaviour passes here and is still wrong in the host); the unclearable warning was
|
|
158
|
+
// not, because a warning nobody can action is how a project learns to skim past the real ones.
|
|
159
|
+
const stubbed = imports.filter((s) => STUBBED.includes(s));
|
|
160
|
+
if (stubbed.length) {
|
|
161
|
+
findings.push({
|
|
162
|
+
level: 'ok',
|
|
163
|
+
check: 'rsc-boundary',
|
|
164
|
+
msg: `uses ${stubbed.join(', ')} — INERT in the lagoon (navigation is a host intent), so this passes here and can still be wrong in the host`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return findings;
|
|
169
|
+
}
|
package/README.md
DELETED