@farm.js/federation 0.1.0-beta.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 ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Farm.js Team
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.
22
+
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @farm.js/federation
2
+
3
+ Publish and consume independently deployed browser modules from Farm applications.
4
+
5
+ ```bash
6
+ pnpm add @farm.js/federation
7
+ ```
8
+
9
+ ```ts
10
+ import { defineConfig } from "@farm.js/core";
11
+ import { federation } from "@farm.js/federation";
12
+
13
+ export default defineConfig({
14
+ plugins: [
15
+ federation({
16
+ name: "storefront",
17
+ remotes: {
18
+ checkout: "https://checkout.example.com/mf-manifest.json",
19
+ },
20
+ }),
21
+ ],
22
+ });
23
+ ```
24
+
25
+ Load configured modules after hydration:
26
+
27
+ ```ts
28
+ import { loadRemote } from "@farm.js/federation/client";
29
+
30
+ const checkout = await loadRemote("checkout/UpgradeCard");
31
+ ```
32
+
33
+ The first release is deliberately browser-only. Server functions, API handlers, Server
34
+ Components, credentials, and other server capabilities stay local to each application. Server
35
+ federation is being designed in [RFC #885](https://github.com/farming-labs/farm.js/issues/885).
36
+
37
+ Read the [Federation Plugin guide](https://farmjs.dev/docs/plugins/federation) for producer setup,
38
+ typed loading, shared dependencies, deployment, and security guidance.
@@ -0,0 +1,13 @@
1
+ export interface FederationRuntimeApi {
2
+ loadRemote<T>(specifier: string): Promise<T | null>;
3
+ preloadRemote(remotes: Array<{
4
+ nameOrAlias: string;
5
+ }>): Promise<unknown>;
6
+ }
7
+ export interface FederationClient {
8
+ loadRemote<TModule>(specifier: string): Promise<TModule>;
9
+ preloadRemote(remotes: string | string[]): Promise<void>;
10
+ }
11
+ export type FederationRuntimeLoader = () => Promise<FederationRuntimeApi>;
12
+ export declare function createFederationClient(loadRuntime: FederationRuntimeLoader, isBrowser?: () => boolean): FederationClient;
13
+ //# sourceMappingURL=client-runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-runtime.d.ts","sourceRoot":"","sources":["../src/client-runtime.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,oBAAoB;IACnC,UAAU,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACpD,aAAa,CAAC,OAAO,EAAE,KAAK,CAAC;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1E;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzD,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1D;AAED,MAAM,MAAM,uBAAuB,GAAG,MAAM,OAAO,CAAC,oBAAoB,CAAC,CAAC;AAE1E,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,uBAAuB,EACpC,SAAS,GAAE,MAAM,OAA6C,GAC7D,gBAAgB,CAqClB"}
@@ -0,0 +1,54 @@
1
+ export function createFederationClient(loadRuntime, isBrowser = () => typeof window !== "undefined") {
2
+ let runtime;
3
+ const getRuntime = () => {
4
+ if (!isBrowser()) {
5
+ throw new Error("[farm:federation] Remote browser modules must be loaded after hydration, for example inside an effect or event handler.");
6
+ }
7
+ if (!runtime) {
8
+ const loading = loadRuntime().catch((error) => {
9
+ runtime = undefined;
10
+ throw error;
11
+ });
12
+ runtime = loading;
13
+ }
14
+ return runtime;
15
+ };
16
+ return {
17
+ async loadRemote(specifier) {
18
+ const normalized = normalizeSpecifier(specifier);
19
+ const module = await (await getRuntime()).loadRemote(normalized);
20
+ if (module === null) {
21
+ throw new Error(`[farm:federation] Remote module ${JSON.stringify(normalized)} was not found`);
22
+ }
23
+ return module;
24
+ },
25
+ async preloadRemote(remotes) {
26
+ const values = (Array.isArray(remotes) ? remotes : [remotes]).map(normalizeRemoteName);
27
+ if (values.length === 0)
28
+ throw new TypeError("Federation preload requires at least one remote");
29
+ await (await getRuntime()).preloadRemote(values.map((nameOrAlias) => ({ nameOrAlias })));
30
+ },
31
+ };
32
+ }
33
+ function normalizeSpecifier(value) {
34
+ const specifier = normalizeRemoteName(value);
35
+ if (!specifier.includes("/") || specifier.endsWith("/")) {
36
+ throw new TypeError("Federation module specifier must contain a remote and exposed module");
37
+ }
38
+ return specifier;
39
+ }
40
+ function normalizeRemoteName(value) {
41
+ if (typeof value !== "string" ||
42
+ !value.trim() ||
43
+ /\s|[?#\\]/.test(value) ||
44
+ hasControlCharacter(value)) {
45
+ throw new TypeError("Federation remote names must be non-empty module specifiers");
46
+ }
47
+ return value.trim();
48
+ }
49
+ function hasControlCharacter(value) {
50
+ return Array.from(value).some((character) => {
51
+ const codePoint = character.codePointAt(0) ?? 0;
52
+ return codePoint <= 0x1f || codePoint === 0x7f;
53
+ });
54
+ }
@@ -0,0 +1,6 @@
1
+ export type { FederationClient } from "./client-runtime.js";
2
+ /** Load one configured remote browser module after Farm hydration. */
3
+ export declare const loadRemote: <TModule>(specifier: string) => Promise<TModule>;
4
+ /** Preload one or more configured producers before their modules are needed. */
5
+ export declare const preloadRemote: (remotes: string | string[]) => Promise<void>;
6
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAU5D,sEAAsE;AACtE,eAAO,MAAM,UAAU,kDAAoB,CAAC;AAE5C,gFAAgF;AAChF,eAAO,MAAM,aAAa,+CAAuB,CAAC"}
package/dist/client.js ADDED
@@ -0,0 +1,12 @@
1
+ import { createFederationClient } from "./client-runtime.js";
2
+ const client = createFederationClient(async () => {
3
+ const runtime = await import("@module-federation/runtime");
4
+ return {
5
+ loadRemote: runtime.loadRemote,
6
+ preloadRemote: runtime.preloadRemote,
7
+ };
8
+ });
9
+ /** Load one configured remote browser module after Farm hydration. */
10
+ export const loadRemote = client.loadRemote;
11
+ /** Preload one or more configured producers before their modules are needed. */
12
+ export const preloadRemote = client.preloadRemote;
@@ -0,0 +1,60 @@
1
+ import type { ModuleFederationOptions } from "@module-federation/vite";
2
+ export interface FederationRemote {
3
+ /** URL of the producer's mf-manifest.json or remoteEntry.js. */
4
+ entry: string;
5
+ /** Producer name. Defaults to the remote alias. */
6
+ name?: string;
7
+ /** Remote entry format. Farm producers use module by default. */
8
+ type?: string;
9
+ /** Module Federation share scope used by this producer. */
10
+ shareScope?: string | string[];
11
+ }
12
+ export interface FederationSharedPackage {
13
+ singleton?: boolean;
14
+ requiredVersion?: string | false;
15
+ strictVersion?: boolean;
16
+ eager?: boolean;
17
+ shareScope?: string;
18
+ import?: string | false;
19
+ }
20
+ export type FederationSharedPackages = string[] | Record<string, true | FederationSharedPackage>;
21
+ export interface FederationSharedOptions {
22
+ /** Share the active Farm renderer and its browser runtime as singletons. Defaults to true. */
23
+ renderer?: boolean;
24
+ /** Additional application packages to share. */
25
+ packages?: FederationSharedPackages;
26
+ }
27
+ export interface FederationOptions {
28
+ /** Stable name of this producer or consumer. */
29
+ name: string;
30
+ /** Browser modules this application publishes. */
31
+ exposes?: Record<string, string | {
32
+ import: string;
33
+ }>;
34
+ /** Independently deployed browser producers this application consumes. */
35
+ remotes?: Record<string, string | FederationRemote>;
36
+ /** Renderer and application dependencies reused across federated applications. */
37
+ shared?: "renderer" | false | FederationSharedOptions;
38
+ /** Generate mf-manifest.json. Defaults to true when modules are exposed. */
39
+ manifest?: boolean;
40
+ /** Remote entry filename. */
41
+ filename?: string;
42
+ /** Public origin or base URL used in generated federation assets. */
43
+ publicPath?: string;
44
+ /** Generate and consume remote TypeScript declarations. Defaults to true. */
45
+ types?: boolean;
46
+ /** Development behavior for independently running producers. */
47
+ dev?: {
48
+ remoteHmr?: boolean | "full-reload";
49
+ };
50
+ }
51
+ export interface FederationRenderer {
52
+ name: string;
53
+ dedupe?: readonly string[];
54
+ }
55
+ export interface ResolvedFederationOptions {
56
+ remoteAliases: string[];
57
+ upstream: ModuleFederationOptions;
58
+ }
59
+ export declare function resolveFederationOptions(options: FederationOptions, renderer: FederationRenderer): ResolvedFederationOptions;
60
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAEvE,MAAM,WAAW,gBAAgB;IAC/B,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,uBAAuB;IACtC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CACzB;AAED,MAAM,MAAM,wBAAwB,GAAG,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,IAAI,GAAG,uBAAuB,CAAC,CAAC;AAEjG,MAAM,WAAW,uBAAuB;IACtC,8FAA8F;IAC9F,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gDAAgD;IAChD,QAAQ,CAAC,EAAE,wBAAwB,CAAC;CACrC;AAED,MAAM,WAAW,iBAAiB;IAChC,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtD,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAAC,CAAC;IACpD,kFAAkF;IAClF,MAAM,CAAC,EAAE,UAAU,GAAG,KAAK,GAAG,uBAAuB,CAAC;IACtD,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6BAA6B;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gEAAgE;IAChE,GAAG,CAAC,EAAE;QACJ,SAAS,CAAC,EAAE,OAAO,GAAG,aAAa,CAAC;KACrC,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,QAAQ,EAAE,uBAAuB,CAAC;CACnC;AAID,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,iBAAiB,EAC1B,QAAQ,EAAE,kBAAkB,GAC3B,yBAAyB,CA0C3B"}
package/dist/config.js ADDED
@@ -0,0 +1,237 @@
1
+ const SERVER_OPTIONS = ["server", "serverRemotes", "ssrExternals", "target", "experiments"];
2
+ export function resolveFederationOptions(options, renderer) {
3
+ assertPlainObject(options, "Federation options");
4
+ for (const field of SERVER_OPTIONS) {
5
+ if (field in options) {
6
+ throw new TypeError(`Federation option ${field} is not available in the client-only release. Follow server federation design in https://github.com/farming-labs/farm.js/issues/885`);
7
+ }
8
+ }
9
+ const name = nonEmptyString(options.name, "Federation name");
10
+ const exposes = normalizeExposes(options.exposes ?? {});
11
+ const remotes = normalizeRemotes(options.remotes ?? {});
12
+ const filename = normalizeOutputFile(options.filename ?? "remoteEntry.js");
13
+ const manifest = booleanOption(options.manifest, "manifest") ?? Object.keys(exposes).length > 0;
14
+ const types = booleanOption(options.types, "types") ?? true;
15
+ const shared = normalizeShared(options.shared, renderer);
16
+ const publicPath = optionalPublicPath(options.publicPath);
17
+ const remoteHmr = options.dev?.remoteHmr;
18
+ if (remoteHmr !== undefined && typeof remoteHmr !== "boolean" && remoteHmr !== "full-reload") {
19
+ throw new TypeError("Federation dev.remoteHmr must be true, false, or full-reload");
20
+ }
21
+ return {
22
+ remoteAliases: Object.keys(remotes),
23
+ upstream: {
24
+ name,
25
+ exposes,
26
+ remotes,
27
+ shared,
28
+ filename,
29
+ manifest,
30
+ dts: types
31
+ ? {
32
+ generateTypes: { compileInChildProcess: false },
33
+ }
34
+ : false,
35
+ target: "web",
36
+ ...(publicPath ? { publicPath } : {}),
37
+ ...(remoteHmr === undefined ? {} : { dev: { remoteHmr } }),
38
+ },
39
+ };
40
+ }
41
+ function normalizeExposes(exposes) {
42
+ assertPlainObject(exposes, "Federation exposes");
43
+ return Object.fromEntries(Object.entries(exposes).map(([key, value]) => {
44
+ if (key !== "." && !key.startsWith("./")) {
45
+ throw new TypeError(`Federation expose ${JSON.stringify(key)} must be . or start with ./`);
46
+ }
47
+ if (typeof value === "string")
48
+ return [key, nonEmptyString(value, `Federation expose ${key}`)];
49
+ assertPlainObject(value, `Federation expose ${key}`);
50
+ return [key, { import: nonEmptyString(value.import, `Federation expose ${key} import`) }];
51
+ }));
52
+ }
53
+ function normalizeRemotes(remotes) {
54
+ assertPlainObject(remotes, "Federation remotes");
55
+ return Object.fromEntries(Object.entries(remotes).map(([alias, value]) => {
56
+ normalizeRemoteAlias(alias);
57
+ if (typeof value === "string") {
58
+ return [alias, normalizeRemoteString(value, alias)];
59
+ }
60
+ assertPlainObject(value, `Federation remote ${alias}`);
61
+ const entry = normalizeRemoteEntry(value.entry, alias);
62
+ const name = value.name === undefined ? alias : nonEmptyString(value.name, `Remote ${alias} name`);
63
+ return [
64
+ alias,
65
+ {
66
+ name,
67
+ entry,
68
+ type: value.type === undefined
69
+ ? "module"
70
+ : nonEmptyString(value.type, `Remote ${alias} type`),
71
+ ...(value.shareScope === undefined
72
+ ? {}
73
+ : { shareScope: normalizeShareScope(value.shareScope, alias) }),
74
+ },
75
+ ];
76
+ }));
77
+ }
78
+ function normalizeShared(shared, renderer) {
79
+ if (shared === false)
80
+ return {};
81
+ if (shared === undefined || shared === "renderer")
82
+ return rendererShares(renderer);
83
+ assertPlainObject(shared, "Federation shared");
84
+ const includeRenderer = booleanOption(shared.renderer, "shared.renderer") ?? true;
85
+ return {
86
+ ...(includeRenderer ? rendererShares(renderer) : {}),
87
+ ...packageShares(shared.packages ?? []),
88
+ };
89
+ }
90
+ function rendererShares(renderer) {
91
+ const roots = new Set((renderer.dedupe ?? []).map(packageRoot).filter(Boolean));
92
+ if (roots.size === 0) {
93
+ throw new TypeError(`Farm renderer ${JSON.stringify(renderer.name)} does not declare packages to share. Set shared to false or provide shared.packages explicitly.`);
94
+ }
95
+ const entries = [];
96
+ for (const root of roots) {
97
+ entries.push([root, { singleton: true }], [`${root}/`, { singleton: true }]);
98
+ }
99
+ return Object.fromEntries(entries);
100
+ }
101
+ function packageShares(shared) {
102
+ if (Array.isArray(shared)) {
103
+ return Object.fromEntries(shared.map((name) => [nonEmptyString(name, "Federation shared package"), {}]));
104
+ }
105
+ assertPlainObject(shared, "Federation shared packages");
106
+ return Object.fromEntries(Object.entries(shared).map(([name, value]) => {
107
+ const normalizedName = nonEmptyString(name, "Federation shared package name");
108
+ if (value === true)
109
+ return [normalizedName, {}];
110
+ assertPlainObject(value, `Federation shared package ${name}`);
111
+ return [normalizedName, { ...value }];
112
+ }));
113
+ }
114
+ function packageRoot(value) {
115
+ if (!value)
116
+ return "";
117
+ const segments = value.split("/");
118
+ return value.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
119
+ }
120
+ function normalizeRemoteAlias(value) {
121
+ const alias = nonEmptyString(value, "Federation remote alias");
122
+ if (/\s|[?#\\]/.test(alias) || alias.startsWith("/") || alias.endsWith("/")) {
123
+ throw new TypeError(`Invalid federation remote alias: ${JSON.stringify(value)}`);
124
+ }
125
+ return alias;
126
+ }
127
+ function normalizeRemoteEntry(value, alias) {
128
+ const entry = nonEmptyString(value, `Federation remote ${alias} entry`);
129
+ if (entry.startsWith("/")) {
130
+ if (entry.startsWith("//") || entry.includes("\\")) {
131
+ throw new TypeError(`Federation remote ${alias} entry must be a root-relative URL, not a network-path URL`);
132
+ }
133
+ return entry;
134
+ }
135
+ if (entry.includes("\\")) {
136
+ throw new TypeError(`Federation remote ${alias} entry must use URL separators`);
137
+ }
138
+ let parsed;
139
+ try {
140
+ parsed = new URL(entry);
141
+ }
142
+ catch {
143
+ throw new TypeError(`Federation remote ${alias} entry must be an HTTP URL or root-relative URL`);
144
+ }
145
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
146
+ parsed.username ||
147
+ parsed.password) {
148
+ throw new TypeError(`Federation remote ${alias} entry must use HTTP without URL credentials`);
149
+ }
150
+ return parsed.href;
151
+ }
152
+ function normalizeRemoteString(value, alias) {
153
+ const remote = nonEmptyString(value, `Federation remote ${alias}`);
154
+ if (/^(?:https?:)?\//i.test(remote))
155
+ return normalizeRemoteEntry(remote, alias);
156
+ const separator = remote.indexOf("@");
157
+ if (separator <= 0 || separator === remote.length - 1) {
158
+ throw new TypeError(`Federation remote ${alias} must be an HTTP URL, root-relative URL, or name@URL`);
159
+ }
160
+ const name = remote.slice(0, separator);
161
+ if (/\s|[/?#\\]/.test(name) || hasControlCharacter(name)) {
162
+ throw new TypeError(`Federation remote ${alias} has an invalid remote name`);
163
+ }
164
+ return `${name}@${normalizeRemoteEntry(remote.slice(separator + 1), alias)}`;
165
+ }
166
+ function normalizeShareScope(value, alias) {
167
+ if (Array.isArray(value)) {
168
+ if (value.length === 0)
169
+ throw new TypeError(`Remote ${alias} shareScope cannot be empty`);
170
+ return value.map((scope) => nonEmptyString(scope, `Remote ${alias} shareScope`));
171
+ }
172
+ return nonEmptyString(value, `Remote ${alias} shareScope`);
173
+ }
174
+ function normalizeOutputFile(value) {
175
+ const filename = nonEmptyString(value, "Federation filename").replace(/^\/+/, "");
176
+ const segments = filename.split("/");
177
+ if (value.includes("\\") ||
178
+ value.includes("?") ||
179
+ value.includes("#") ||
180
+ segments.some((segment) => !segment || segment === "." || segment === "..") ||
181
+ !/\.m?js$/i.test(filename)) {
182
+ throw new TypeError("Federation filename must be a safe relative .js or .mjs path");
183
+ }
184
+ return filename;
185
+ }
186
+ function optionalPublicPath(value) {
187
+ if (value === undefined)
188
+ return undefined;
189
+ const publicPath = nonEmptyString(value, "Federation publicPath");
190
+ if (publicPath === "auto")
191
+ return publicPath;
192
+ if (publicPath.startsWith("/")) {
193
+ if (publicPath.startsWith("//") || publicPath.includes("\\")) {
194
+ throw new TypeError("Federation publicPath must be a root-relative URL, not a network-path URL");
195
+ }
196
+ return publicPath;
197
+ }
198
+ if (publicPath.includes("\\")) {
199
+ throw new TypeError("Federation publicPath must use URL separators");
200
+ }
201
+ let parsed;
202
+ try {
203
+ parsed = new URL(publicPath);
204
+ }
205
+ catch {
206
+ throw new TypeError("Federation publicPath must be auto, root-relative, or an HTTP URL");
207
+ }
208
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
209
+ parsed.username ||
210
+ parsed.password) {
211
+ throw new TypeError("Federation publicPath must use HTTP without URL credentials");
212
+ }
213
+ return parsed.href;
214
+ }
215
+ function booleanOption(value, label) {
216
+ if (value !== undefined && typeof value !== "boolean") {
217
+ throw new TypeError(`Federation ${label} must be a boolean`);
218
+ }
219
+ return value;
220
+ }
221
+ function nonEmptyString(value, label) {
222
+ if (typeof value !== "string" || !value.trim() || hasControlCharacter(value)) {
223
+ throw new TypeError(`${label} must be a non-empty string without control characters`);
224
+ }
225
+ return value.trim();
226
+ }
227
+ function hasControlCharacter(value) {
228
+ return Array.from(value).some((character) => {
229
+ const codePoint = character.codePointAt(0) ?? 0;
230
+ return codePoint <= 0x1f || codePoint === 0x7f;
231
+ });
232
+ }
233
+ function assertPlainObject(value, label) {
234
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
235
+ throw new TypeError(`${label} must be an object`);
236
+ }
237
+ }
@@ -0,0 +1,5 @@
1
+ import { type FederationOptions, type FederationRemote, type FederationSharedOptions, type FederationSharedPackage, type FederationSharedPackages } from "./config.js";
2
+ export type { FederationOptions, FederationRemote, FederationSharedOptions, FederationSharedPackage, FederationSharedPackages, };
3
+ /** Publish and consume independently deployed browser modules from a Farm application. */
4
+ export declare function federation(options: FederationOptions): import("@farm.js/core/plugin").FarmPlugin<undefined, Record<string, never>, unknown, undefined, unknown, false>;
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,EAC9B,MAAM,aAAa,CAAC;AAGrB,YAAY,EACV,iBAAiB,EACjB,gBAAgB,EAChB,uBAAuB,EACvB,uBAAuB,EACvB,wBAAwB,GACzB,CAAC;AAEF,0FAA0F;AAC1F,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,mHAyBpD"}
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ import { definePlugin } from "@farm.js/core/plugin";
2
+ import { resolveFederationOptions, } from "./config.js";
3
+ import { createFarmFederationVitePlugins } from "./vite.js";
4
+ /** Publish and consume independently deployed browser modules from a Farm application. */
5
+ export function federation(options) {
6
+ return definePlugin({
7
+ name: "farm:federation",
8
+ enforce: "post",
9
+ configure(config) {
10
+ if ((config.plugins ?? []).filter((candidate) => candidate.name === "farm:federation").length >
11
+ 1) {
12
+ throw new Error("[farm:federation] Configure federation in one federation() instance");
13
+ }
14
+ const renderer = config.renderer ?? { name: "react", dedupe: ["react", "react-dom"] };
15
+ const resolved = resolveFederationOptions(options, renderer);
16
+ const vite = config.vite ?? {};
17
+ return {
18
+ ...config,
19
+ vite: {
20
+ ...vite,
21
+ plugins: [...(vite.plugins ?? []), ...createFarmFederationVitePlugins(resolved)],
22
+ },
23
+ };
24
+ },
25
+ });
26
+ }
package/dist/vite.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { ResolvedFederationOptions } from "./config.js";
2
+ interface ViteConfigLike {
3
+ build?: {
4
+ ssr?: unknown;
5
+ rollupOptions?: {
6
+ output?: FederationOutputOptions | FederationOutputOptions[];
7
+ };
8
+ rolldownOptions?: {
9
+ output?: FederationOutputOptions | FederationOutputOptions[];
10
+ };
11
+ };
12
+ }
13
+ interface FederationOutputOptions {
14
+ codeSplitting?: {
15
+ groups?: Array<Record<string, unknown>>;
16
+ };
17
+ [key: string]: unknown;
18
+ }
19
+ interface ViteEnvironmentLike {
20
+ command: string;
21
+ }
22
+ interface VitePluginLike {
23
+ name?: string;
24
+ apply?: string | ((config: ViteConfigLike, environment: ViteEnvironmentLike) => boolean | undefined);
25
+ resolveId?: unknown;
26
+ load?: unknown;
27
+ transform?: unknown;
28
+ [key: string]: unknown;
29
+ }
30
+ type VitePluginOption = VitePluginLike | false | null | undefined | Promise<VitePluginOption> | VitePluginOption[];
31
+ export declare function createFarmFederationVitePlugins(options: ResolvedFederationOptions, factory?: (options: ResolvedFederationOptions["upstream"]) => VitePluginOption[]): VitePluginOption[];
32
+ export declare function restrictPluginOptionToClient(option: VitePluginOption): VitePluginOption;
33
+ export declare function restrictPluginToClient(plugin: VitePluginLike): VitePluginLike;
34
+ export {};
35
+ //# sourceMappingURL=vite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAE7D,UAAU,cAAc;IACtB,KAAK,CAAC,EAAE;QACN,GAAG,CAAC,EAAE,OAAO,CAAC;QACd,aAAa,CAAC,EAAE;YACd,MAAM,CAAC,EAAE,uBAAuB,GAAG,uBAAuB,EAAE,CAAC;SAC9D,CAAC;QACF,eAAe,CAAC,EAAE;YAChB,MAAM,CAAC,EAAE,uBAAuB,GAAG,uBAAuB,EAAE,CAAC;SAC9D,CAAC;KACH,CAAC;CACH;AAED,UAAU,uBAAuB;IAC/B,aAAa,CAAC,EAAE;QACd,MAAM,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;KACzC,CAAC;IACF,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,UAAU,mBAAmB;IAC3B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,UAAU,cAAc;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EACF,MAAM,GACN,CAAC,CAAC,MAAM,EAAE,cAAc,EAAE,WAAW,EAAE,mBAAmB,KAAK,OAAO,GAAG,SAAS,CAAC,CAAC;IACxF,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,KAAK,gBAAgB,GACjB,cAAc,GACd,KAAK,GACL,IAAI,GACJ,SAAS,GACT,OAAO,CAAC,gBAAgB,CAAC,GACzB,gBAAgB,EAAE,CAAC;AAMvB,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,yBAAyB,EAClC,OAAO,GAAE,CAAC,OAAO,EAAE,yBAAyB,CAAC,UAAU,CAAC,KAAK,gBAAgB,EAAmB,GAC/F,gBAAgB,EAAE,CAQpB;AAED,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,gBAAgB,GAAG,gBAAgB,CASvF;AA4BD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,cAAc,GAAG,cAAc,CAoB7E"}
package/dist/vite.js ADDED
@@ -0,0 +1,173 @@
1
+ import { federation as viteFederation } from "@module-federation/vite";
2
+ const SERVER_RUNTIME_ID = "\0farm:federation:server-runtime";
3
+ export function createFarmFederationVitePlugins(options, factory = viteFederation) {
4
+ const upstream = factory(options.upstream).map(restrictPluginOptionToClient);
5
+ return [
6
+ serverImportGuard(options.remoteAliases),
7
+ isolateFarmExposes(),
8
+ ...upstream,
9
+ repairFarmManifest(),
10
+ ];
11
+ }
12
+ export function restrictPluginOptionToClient(option) {
13
+ if (option instanceof Promise) {
14
+ return option.then(restrictPluginOptionToClient);
15
+ }
16
+ if (Array.isArray(option)) {
17
+ return option.map(restrictPluginOptionToClient);
18
+ }
19
+ if (!option || typeof option !== "object")
20
+ return option;
21
+ return restrictPluginToClient(option);
22
+ }
23
+ function isolateFarmExposes() {
24
+ return {
25
+ name: "farm:federation:output-boundary",
26
+ apply: "build",
27
+ enforce: "post",
28
+ config(config) {
29
+ if (config.build?.ssr)
30
+ return;
31
+ for (const output of [
32
+ config.build?.rollupOptions?.output,
33
+ config.build?.rolldownOptions?.output,
34
+ ]) {
35
+ for (const candidate of Array.isArray(output) ? output : output ? [output] : []) {
36
+ const codeSplitting = (candidate.codeSplitting ??= {});
37
+ const groups = (codeSplitting.groups ??= []);
38
+ if (groups.some((group) => group.name === "federation-exposes"))
39
+ continue;
40
+ groups.push({
41
+ name: "federation-exposes",
42
+ test: /virtual:mf-exposes:/,
43
+ priority: 999_999,
44
+ });
45
+ }
46
+ }
47
+ },
48
+ };
49
+ }
50
+ export function restrictPluginToClient(plugin) {
51
+ const originalApply = plugin.apply;
52
+ const wrapped = {
53
+ ...plugin,
54
+ apply(config, environment) {
55
+ if (config.build?.ssr)
56
+ return false;
57
+ // Farm uses a short-lived Vite serve environment for production route discovery.
58
+ // Treat it as part of the build so upstream development workers do not outlive the CLI.
59
+ if (environment.command === "serve" && process.env.NODE_ENV === "production")
60
+ return false;
61
+ if (typeof originalApply === "function") {
62
+ return originalApply.call(this, config, environment);
63
+ }
64
+ return originalApply === undefined || originalApply === environment.command;
65
+ },
66
+ };
67
+ wrapped.resolveId = wrapEnvironmentHook(plugin.resolveId, 2);
68
+ wrapped.load = wrapEnvironmentHook(plugin.load, 1);
69
+ wrapped.transform = wrapEnvironmentHook(plugin.transform, 2);
70
+ return wrapped;
71
+ }
72
+ function serverImportGuard(remoteAliases) {
73
+ return {
74
+ name: "farm:federation:server-import-guard",
75
+ enforce: "pre",
76
+ resolveId(id, _importer, options) {
77
+ if (!isServerEnvironment(this, options))
78
+ return null;
79
+ if (id === "@module-federation/runtime")
80
+ return SERVER_RUNTIME_ID;
81
+ if (!matchesRemote(id, remoteAliases))
82
+ return null;
83
+ const message = `[farm:federation] Remote module ${JSON.stringify(id)} entered Farm's server graph. ` +
84
+ "The current release federates browser modules only. Load it after hydration with " +
85
+ "@farm.js/federation/client. Server federation is tracked at " +
86
+ "https://github.com/farming-labs/farm.js/issues/885";
87
+ if (this.error)
88
+ return this.error(message);
89
+ throw new Error(message);
90
+ },
91
+ load(id) {
92
+ if (id !== SERVER_RUNTIME_ID)
93
+ return null;
94
+ return `
95
+ const serverError = () => {
96
+ throw new Error("[farm:federation] Remote browser modules must be loaded after hydration with @farm.js/federation/client.");
97
+ };
98
+ export const loadRemote = serverError;
99
+ export const preloadRemote = serverError;
100
+ `;
101
+ },
102
+ };
103
+ }
104
+ function repairFarmManifest() {
105
+ return {
106
+ name: "farm:federation:manifest",
107
+ apply: "build",
108
+ enforce: "post",
109
+ generateBundle(_output, bundle) {
110
+ const remoteEntry = Object.values(bundle).find((output) => {
111
+ if (!output || typeof output !== "object")
112
+ return false;
113
+ const chunk = output;
114
+ return (chunk.type === "chunk" &&
115
+ typeof chunk.facadeModuleId === "string" &&
116
+ chunk.facadeModuleId.includes("mf-REMOTE_ENTRY_ID"));
117
+ });
118
+ if (!remoteEntry || typeof remoteEntry.fileName !== "string")
119
+ return;
120
+ for (const output of Object.values(bundle)) {
121
+ if (!output || typeof output !== "object")
122
+ continue;
123
+ const asset = output;
124
+ if (asset.type !== "asset" ||
125
+ typeof asset.fileName !== "string" ||
126
+ !/(?:^|\/)(?:mf-manifest|mf-stats)\.json$/.test(asset.fileName)) {
127
+ continue;
128
+ }
129
+ const source = typeof asset.source === "string" ? asset.source : asset.source?.toString();
130
+ if (!source)
131
+ continue;
132
+ const document = JSON.parse(source);
133
+ if (!document.metaData?.remoteEntry || document.metaData.remoteEntry.name)
134
+ continue;
135
+ document.metaData.remoteEntry.name = remoteEntry.fileName;
136
+ asset.source = JSON.stringify(document);
137
+ }
138
+ },
139
+ };
140
+ }
141
+ function wrapEnvironmentHook(hook, optionsIndex) {
142
+ if (typeof hook === "function") {
143
+ return function (...args) {
144
+ if (isServerEnvironment(this, args[optionsIndex]))
145
+ return null;
146
+ return hook.apply(this, args);
147
+ };
148
+ }
149
+ if (hook &&
150
+ typeof hook === "object" &&
151
+ typeof hook.handler === "function") {
152
+ const hookObject = hook;
153
+ return {
154
+ ...hookObject,
155
+ handler(...args) {
156
+ if (isServerEnvironment(this, args[optionsIndex]))
157
+ return null;
158
+ return hookObject.handler.apply(this, args);
159
+ },
160
+ };
161
+ }
162
+ return hook;
163
+ }
164
+ function isServerEnvironment(context, options) {
165
+ const environmentName = context?.environment?.name;
166
+ return (environmentName === "rsc" ||
167
+ environmentName === "ssr" ||
168
+ environmentName === "server" ||
169
+ Boolean(options?.ssr));
170
+ }
171
+ function matchesRemote(id, aliases) {
172
+ return (typeof id === "string" && aliases.some((alias) => id === alias || id.startsWith(`${alias}/`)));
173
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@farm.js/federation",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Client-side Module Federation for Farm.js applications",
5
+ "keywords": [
6
+ "farm.js",
7
+ "micro-frontends",
8
+ "module-federation",
9
+ "vite"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/farming-labs/farm.js",
15
+ "directory": "packages/farm-federation"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "module": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ },
29
+ "./client": {
30
+ "types": "./dist/client.d.ts",
31
+ "import": "./dist/client.js"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@module-federation/runtime": "^2.9.0",
39
+ "@module-federation/vite": "^1.21.6",
40
+ "@farm.js/core": "0.1.0-beta.92"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.10.5",
44
+ "typescript": "^5.3.3",
45
+ "vitest": "^3.2.7"
46
+ },
47
+ "engines": {
48
+ "node": ">=22.13.0"
49
+ },
50
+ "scripts": {
51
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
52
+ "dev": "tsc --watch",
53
+ "type-check": "tsc --noEmit",
54
+ "test": "vitest run"
55
+ }
56
+ }