Skip to content

Commit 4cb0d4d

Browse files
committed
Step 2 - Implement custom loader for SSR: handle external files, load mini-SPA modules, update importmap, use http prefixes, and apply resolve-based solution for dev-server.
1 parent fddb813 commit 4cb0d4d

File tree

10 files changed

+329
-11
lines changed

10 files changed

+329
-11
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const IMPORT_MAP = 'IMPORT_MAP';
2+
export const CACHE_FILE = 'CACHE_FILE';
Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,88 @@
1+
// @ts-expect-error only for types
2+
import { ImportMap, IImportMap } from '@jspm/import-map';
3+
import { pathToFileURL } from 'url';
4+
import {
5+
loadEsmModule,
6+
DeferredPromise,
7+
checkIfNodeProtocol,
8+
checkIfFileProtocol,
9+
resolveModulePath,
10+
asyncCustomResolve,
11+
} from './custom-loader-utils';
12+
113
import { Context, DefaultLoad, NextResolve } from './types';
14+
import { CACHE_FILE, IMPORT_MAP } from './constants';
215
import { join } from 'path';
16+
import { PREF } from './patch-vite-dev-server';
17+
import { OutputFileRecord } from '../types';
18+
import { getResultImportMap, IMPORT_MAP_CONFIG_NAME } from '../helpers';
19+
20+
const fakeRootPath = pathToFileURL('tmp/file/').href;
21+
const mapUrlDeferred = new DeferredPromise<{
22+
importMap: IImportMap;
23+
rootUrlHost: string;
24+
}>();
25+
26+
const cacheFilesDeferred = new DeferredPromise<Map<string, OutputFileRecord>>();
27+
const importMapModulePromise = loadEsmModule<{
28+
ImportMap: new (...args: any[]) => ImportMap;
29+
}>('@jspm/import-map').then((r) => r.ImportMap);
30+
31+
let start = false;
32+
let importMap: ImportMap;
33+
34+
let cacheFiles: Map<string, OutputFileRecord> = new Map<
35+
string,
36+
OutputFileRecord
37+
>();
38+
const packageNameToImportNameMap = new Map<string, string>();
39+
40+
41+
async function getImportMap() {
42+
if (importMap) return importMap;
43+
44+
const ImportMap = await importMapModulePromise;
45+
const { importMap: importMapJson, rootUrlHost } = await mapUrlDeferred;
46+
47+
importMap = new ImportMap({
48+
map: importMapJson,
49+
mapUrl: rootUrlHost,
50+
});
51+
}
52+
53+
async function getCacheFiles() {
54+
await cacheFilesDeferred;
55+
return cacheFiles;
56+
}
357

458
export async function initialize({ port }: { port: MessagePort }) {
5-
port.onmessage = async (event) => {};
59+
port.onmessage = async (event) => {
60+
switch (event.data.kind) {
61+
case IMPORT_MAP:
62+
mapUrlDeferred.resolve(event.data.result);
63+
break;
64+
case CACHE_FILE:
65+
cacheFiles = event.data.result;
66+
for (const value of cacheFiles.values()) {
67+
packageNameToImportNameMap.set(value.packageName, value.mapName);
68+
}
69+
cacheFilesDeferred.resolve(event.data.result);
70+
break;
71+
}
72+
};
673
}
774

875
export async function resolve(
9-
specifier: string,
76+
specifierInput: string,
1077
context: Context,
1178
nextResolve: NextResolve
1279
) {
1380
const { parentURL } = context;
81+
const specifier = specifierInput.replace(PREF, '');
1482
if (
1583
specifier.startsWith('vite') &&
16-
parentURL.indexOf('@angular/build') > -1
84+
(parentURL.indexOf('@angular/build') > -1 ||
85+
parentURL.indexOf('custom-loader-utils') > -1)
1786
) {
1887
return nextResolve(
1988
join(__dirname, 'patch-vite-dev-server.js'),
@@ -22,13 +91,113 @@ export async function resolve(
2291
);
2392
}
2493

25-
return nextResolve(specifier, context, nextResolve);
94+
if (!start && parentURL.indexOf('vite/dist/node') > -1) {
95+
start = true;
96+
}
97+
98+
if (!start) return nextResolve(specifier, context, nextResolve);
99+
100+
if (
101+
parentURL.indexOf('@angular/compiler-cli') > -1 ||
102+
parentURL.indexOf('@angular/build') > -1
103+
) {
104+
return nextResolve(specifier, context, nextResolve);
105+
}
106+
107+
const importMap = await getImportMap();
108+
const importMapName = packageNameToImportNameMap.get(specifier);
109+
const resolveUrl = resolveModulePath(importMap, specifier, parentURL);
110+
111+
if (checkIfNodeProtocol(resolveUrl) || checkIfFileProtocol(resolveUrl)) {
112+
return nextResolve(specifier, context, nextResolve);
113+
}
114+
115+
if (!importMapName && !resolveUrl) {
116+
try {
117+
const fileUrl = await asyncCustomResolve(specifier);
118+
const pathToFile = pathToFileURL(fileUrl);
119+
const pathName = new URL(parentURL).pathname;
120+
const resultFromImport = Object.entries(importMap.toJSON().imports).find(
121+
([key, val]) => val.endsWith(pathName)
122+
);
123+
if (resultFromImport) {
124+
context.parentURL = resultFromImport[0].replace(PREF, '');
125+
}
126+
return nextResolve(pathToFile.toString(), context, nextResolve);
127+
} catch (e) {
128+
return nextResolve(specifier, context, nextResolve);
129+
}
130+
}
131+
132+
const specifierUrl = new URL(specifier, fakeRootPath);
133+
134+
return {
135+
url: specifierUrl.toString(),
136+
shortCircuit: true,
137+
};
26138
}
27139

28140
export async function load(
29141
url: string,
30142
context: Context,
31143
defaultLoad: DefaultLoad
32144
) {
145+
url = url.split('?').at(0);
146+
147+
const specifier = url.replace(fakeRootPath, '');
148+
149+
const importMapName = packageNameToImportNameMap.get(specifier);
150+
const { parentURL } = context;
151+
152+
if (importMapName) {
153+
const cacheFiles = await getCacheFiles();
154+
const hasCache = cacheFiles.get(importMapName);
155+
if (hasCache) {
156+
const content = `
157+
var ngServerMode = true;
158+
${new TextDecoder().decode(hasCache.contents)}
159+
`;
160+
161+
return {
162+
format: 'module',
163+
source: content,
164+
shortCircuit: true,
165+
};
166+
}
167+
}
168+
const importMap = await getImportMap();
169+
const resolveUrl = resolveModulePath(importMap, specifier, parentURL);
170+
if (!resolveUrl) return defaultLoad(url, context, defaultLoad);
171+
const originalUrl = new URL(resolveUrl);
172+
if (importMap.scopes[originalUrl.origin + '/']) {
173+
try {
174+
const importJson = await fetch(
175+
new URL(IMPORT_MAP_CONFIG_NAME, originalUrl.origin).toString()
176+
).then((r) => r.json());
177+
const importMapScope = await getResultImportMap(importJson);
178+
for (const [key, value] of Object.entries<string>(
179+
importMapScope.imports
180+
)) {
181+
if (!value.endsWith(importMap.imports[key])) {
182+
importMap.set(key, `${value}`, originalUrl.origin);
183+
}
184+
}
185+
const response = await fetch(originalUrl).then((r) => r.text());
186+
const content = `
187+
var ngServerMode = true;
188+
${response}
189+
`;
190+
191+
return {
192+
format: 'module',
193+
source: content,
194+
shortCircuit: true,
195+
};
196+
} catch (e) {
197+
console.error('Load scope dep', e);
198+
return defaultLoad(url, context, defaultLoad);
199+
}
200+
}
201+
33202
return defaultLoad(url, context, defaultLoad);
34203
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// @ts-expect-error only for types
2+
import type { ImportMap } from '@jspm/import-map';
3+
import resolver from 'enhanced-resolve';
4+
5+
export function loadEsmModule<T>(modulePath: string | URL): Promise<T> {
6+
return new Function('modulePath', `return import(modulePath);`)(
7+
modulePath
8+
) as Promise<T>;
9+
}
10+
11+
export const checkIfNodeProtocol = (modulePath: string) => {
12+
if (!modulePath) return false;
13+
const { protocol = '' } = new URL(modulePath);
14+
return protocol === 'node:';
15+
};
16+
export const checkIfFileProtocol = (modulePath: string) => {
17+
if (!modulePath) return false;
18+
const { protocol = '' } = new URL(modulePath);
19+
return protocol === 'file:';
20+
};
21+
22+
export const resolveModulePath = (
23+
importMap: ImportMap,
24+
specifier: string,
25+
parentURL: string
26+
): string | null => {
27+
try {
28+
return importMap.resolve(specifier, parentURL);
29+
} catch {
30+
return null;
31+
}
32+
};
33+
34+
const myResolver = resolver.create({
35+
conditionNames: ['import', 'node', 'default'],
36+
});
37+
38+
export function asyncCustomResolve(specifier: string) {
39+
return new Promise<string>((resolve, reject) => {
40+
myResolver(__dirname, specifier, (err, res) => {
41+
if (err || !res) return reject(err);
42+
resolve(res);
43+
});
44+
});
45+
}
46+
47+
export class DeferredPromise<T> {
48+
[Symbol.toStringTag]!: 'Promise';
49+
50+
private _promise: Promise<T>;
51+
private _resolve!: (value: T | PromiseLike<T>) => void;
52+
private _reject!: (reason?: any) => void;
53+
private _finally!: (
54+
onfinally?: (() => void) | undefined | null
55+
) => Promise<T>;
56+
private _state: 'pending' | 'fulfilled' | 'rejected' = 'pending';
57+
58+
public get state(): 'pending' | 'fulfilled' | 'rejected' {
59+
return this._state;
60+
}
61+
62+
constructor() {
63+
this._promise = new Promise<T>((resolve, reject) => {
64+
this._resolve = resolve;
65+
this._reject = reject;
66+
});
67+
}
68+
69+
public then<TResult1, TResult2>(
70+
onfulfilled?: (value: T) => TResult1 | PromiseLike<TResult1>,
71+
onrejected?: (reason: any) => TResult2 | PromiseLike<TResult2>
72+
): Promise<TResult1 | TResult2> {
73+
return this._promise.then(onfulfilled, onrejected);
74+
}
75+
76+
public catch<TResult>(
77+
onrejected?: (reason: any) => TResult | PromiseLike<TResult>
78+
): Promise<T | TResult> {
79+
return this._promise.catch(onrejected);
80+
}
81+
82+
public resolve(value: T | PromiseLike<T>): void {
83+
this._resolve(value);
84+
this._state = 'fulfilled';
85+
}
86+
87+
public reject(reason?: any): void {
88+
this._reject(reason);
89+
this._state = 'rejected';
90+
}
91+
}

libs/nx-angular-mf/src/builders/es-plugin/import-map-config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { sep } from 'path';
33
import { ConfigMf } from '../types';
44
import { getDataForImportMap, IMPORT_MAP_CONFIG_NAME } from '../helpers';
55

6-
export function importMapConfigPlugin(config: ConfigMf): Plugin {
6+
export function importMapConfigPlugin(config: ConfigMf, isDev = false): Plugin {
77
return {
88
name: 'importMapConfig',
99
setup(build: PluginBuild) {
@@ -36,7 +36,7 @@ export function importMapConfigPlugin(config: ConfigMf): Plugin {
3636
(i) => i.path.includes(name) && !i.path.endsWith('.map')
3737
);
3838
importMapResult.contents = new TextEncoder().encode(
39-
JSON.stringify(getDataForImportMap(config))
39+
JSON.stringify(getDataForImportMap(config, isDev))
4040
);
4141

4242
const pathsArray = importMapResult.path.split(sep);

libs/nx-angular-mf/src/builders/helpers/transform-html.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ function removeScriptModules(node) {
6666

6767
export async function indexHtml(
6868
mfeConfig: ConfigMf,
69+
isDev = false
6970
): Promise<(input: string) => Promise<string>> {
70-
const dataImport = getDataForImportMap(mfeConfig);
71+
const dataImport = getDataForImportMap(mfeConfig, isDev);
7172
const allImportMap = await getResultImportMap(dataImport);
7273
mfeConfig.allImportMap = allImportMap;
7374
return async (input: string) => {

libs/nx-angular-mf/src/builders/helpers/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ConfigMf, DataForImportMap } from '../types';
55
import { getMapName } from './dependencies';
66
import { existsSync } from 'fs';
77
import { pathToFileURL } from 'node:url';
8+
import { PREF } from '../custom-loader/patch-vite-dev-server';
89

910
export const workspaceRootPath = getSystemPath(normalize(workspaceRoot));
1011

@@ -47,14 +48,19 @@ export function deepMergeObject(targetObject = {}, sourceObject = {}) {
4748

4849
export function getDataForImportMap(
4950
mfeConfig: ConfigMf,
51+
isDev = false
5052
): DataForImportMap {
5153
const mapShareObject = getMapName(mfeConfig.shared, mfeConfig.sharedMappings);
5254
return {
5355
imports: [...mapShareObject.entries()].reduce((acum, [key, val]) => {
5456
const resultName =
5557
mfeConfig.outPutFileNames.find((i) => i.startsWith(key)) || key + '.js';
5658

59+
if (isDev) {
60+
acum[PREF + val.packageName] = `${mfeConfig.deployUrl}${resultName}`;
61+
}
5762
acum[val.packageName] = `${mfeConfig.deployUrl}${resultName}`;
63+
5864
return acum;
5965
}, {}),
6066
exposes: Object.entries(mfeConfig.exposes).reduce((acum, [key, val]) => {

0 commit comments

Comments
 (0)