Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
* @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks.
*/
const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=true;');
const UTF8_DECODER = new TextDecoder();

/**
* Node.js ESM loader to redirect imports to in memory files.
Expand All @@ -25,12 +26,12 @@ const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=tru
const MEMORY_URL_SCHEME = 'memory://';

export interface ESMInMemoryFileLoaderWorkerData {
outputFiles: Record<string, string>;
outputFiles: Record<string, string | Uint8Array>;
workspaceRoot: string;
}

let memoryVirtualRootUrl: string;
let outputFiles: Record<string, string>;
let outputFiles: Record<string, string | Uint8Array>;

export function initialize(data: ESMInMemoryFileLoaderWorkerData) {
// This path does not actually exist but is used to overlay the in memory files with the
Expand Down Expand Up @@ -84,7 +85,7 @@ export function resolve(
} catch {}

if (
specifierUrl?.pathname &&
specifierUrl?.href.startsWith(memoryVirtualRootUrl) &&
Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length))
) {
return {
Expand Down Expand Up @@ -114,12 +115,14 @@ export async function load(url: string, context: { format?: string | null }, nex

// Load the file from memory if the URL is based in the virtual root
if (url.startsWith(memoryVirtualRootUrl)) {
const source = outputFiles[url.slice(memoryVirtualRootUrl.length)];
assert(source !== undefined, 'Resolved in-memory ESM file should always exist: ' + url);
const rawSource = outputFiles[url.slice(memoryVirtualRootUrl.length)];
assert(rawSource !== undefined, 'Resolved in-memory ESM file should always exist: ' + url);

const source = typeof rawSource === 'string' ? rawSource : UTF8_DECODER.decode(rawSource);

// In-memory files have already been transformer during bundling and can be returned directly
return {
format,
format: format ?? 'module',
shortCircuit: true,
source,
};
Expand All @@ -128,14 +131,15 @@ export async function load(url: string, context: { format?: string | null }, nex
// Only module files potentially require transformation. Angular libraries that would
// need linking are ESM only.
if (format === 'module' && isFileProtocol(url)) {
const filePath = fileURLToPath(url);
let source = await readFile(filePath);

if (filePath.includes('@angular/')) {
// Prepend 'var ngServerMode=true;' to the source.
source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, source]);
// Check url instead of filePath so the check is robust across Windows and POSIX path separators.
if (!url.includes('/@angular/')) {
return nextLoad(url, context);
}

const filePath = fileURLToPath(url);
const fileBytes = await readFile(filePath);
const source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, fileBytes]);

return {
format,
shortCircuit: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { initialize, load, resolve } from './loader-hooks';
import { createSharedServerFiles } from './utils';

describe('esm-in-memory-loader loader-hooks', () => {
const workspaceRoot = '/mock/workspace/root';
const sharedFiles = createSharedServerFiles({
'main.server.mjs': 'export const main = true;',
'chunk-abc.mjs': 'export const chunk = "abc";',
'nested/chunk-sub.mjs': 'export const sub = "sub";',
'utf8.mjs': 'export const text = "🔥 UTF-8 🚀";',
'empty.mjs': '',
});

beforeEach(() => {
initialize({
workspaceRoot,
outputFiles: sharedFiles,
});
});

describe('resolve', () => {
it('should resolve memory:// URLs into virtual filesystem URLs', () => {
const nextResolve = jasmine.createSpy('nextResolve');
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
const result = resolve(memoryUrl, { parentURL: undefined }, nextResolve);

expect(nextResolve).not.toHaveBeenCalled();
expect(result.format).toBe('module');
expect(result.shortCircuit).toBeTrue();
expect(result.url).toContain('/.angular/prerender-root/');
expect(result.url).toContain('/main.server.mjs');
});

it('should fail when memory:// URL is malformed', () => {
const nextResolve = jasmine.createSpy('nextResolve');
expect(() => {
resolve('memory://::invalid', { parentURL: undefined }, nextResolve);
}).toThrowMatching((err: Error) =>
err.message.includes('External code attempted to use malformed memory scheme'),
);
expect(nextResolve).not.toHaveBeenCalled();
});

it('should resolve relative specifiers within in-memory files', () => {
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const parentURL = rootResolve.url;

const nextResolve = jasmine.createSpy('nextResolve');
const result = resolve('./chunk-abc.mjs', { parentURL }, nextResolve);

expect(nextResolve).not.toHaveBeenCalled();
expect(result.format).toBe('module');
expect(result.shortCircuit).toBeTrue();
expect(result.url).toContain('/chunk-abc.mjs');
});

it('should resolve relative specifiers navigating parent directories within in-memory files', () => {
const memoryUrl = new URL('./nested/chunk-sub.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const parentURL = rootResolve.url;

const nextResolve = jasmine.createSpy('nextResolve');
const result = resolve('../chunk-abc.mjs', { parentURL }, nextResolve);

expect(nextResolve).not.toHaveBeenCalled();
expect(result.format).toBe('module');
expect(result.shortCircuit).toBeTrue();
expect(result.url).toContain('/chunk-abc.mjs');
});

it('should fail when relative specifier from in-memory file does not exist', () => {
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const parentURL = rootResolve.url;

const nextResolve = jasmine.createSpy('nextResolve');
expect(() => {
resolve('./non-existent.mjs', { parentURL }, nextResolve);
}).toThrowMatching((err: Error) =>
err.message.includes('In-memory ESM relative file should always exist'),
);
expect(nextResolve).not.toHaveBeenCalled();
});

it('should rewrite parentURL to index.js in virtual root for bare package specifiers', () => {
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const parentURL = rootResolve.url;

const nextResolve = jasmine
.createSpy('nextResolve')
.and.returnValue({ url: 'file:///some/node_modules/@angular/core/index.js' });
const result = resolve('@angular/core', { parentURL }, nextResolve);

expect(nextResolve).toHaveBeenCalledWith(
'@angular/core',
jasmine.objectContaining({
parentURL: jasmine.stringMatching(/\/\.angular\/prerender-root\/[^/]+\/index\.js$/),
}),
);
expect(result.url).toBe('file:///some/node_modules/@angular/core/index.js');
});

it('should delegate to nextResolve for external non-memory URLs', () => {
const nextResolve = jasmine
.createSpy('nextResolve')
.and.returnValue({ url: 'file:///some/ext/pkg' });
const result = resolve('some-pkg', { parentURL: 'file:///some/ext/file.js' }, nextResolve);

expect(nextResolve).toHaveBeenCalledWith('some-pkg', {
parentURL: 'file:///some/ext/file.js',
});
expect(result.url).toBe('file:///some/ext/pkg');
});
});

describe('load', () => {
it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', async () => {
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const nextLoad = jasmine.createSpy('nextLoad');

const result = await load(rootResolve.url, { format: 'module' }, nextLoad);

expect(nextLoad).not.toHaveBeenCalled();
expect(result.format).toBe('module');
expect(result.shortCircuit).toBeTrue();
expect(result.source).toBe('export const main = true;');
});

it('should load in-memory file with multi-byte UTF-8 characters', async () => {
const memoryUrl = new URL('./utf8.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const nextLoad = jasmine.createSpy('nextLoad');

const result = await load(rootResolve.url, { format: 'module' }, nextLoad);

expect(nextLoad).not.toHaveBeenCalled();
expect(result.format).toBe('module');
expect(result.shortCircuit).toBeTrue();
expect(result.source).toBe('export const text = "🔥 UTF-8 🚀";');
});

it('should load in-memory file with empty content', async () => {
const memoryUrl = new URL('./empty.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const nextLoad = jasmine.createSpy('nextLoad');

const result = await load(rootResolve.url, { format: 'module' }, nextLoad);

expect(nextLoad).not.toHaveBeenCalled();
expect(result.format).toBe('module');
expect(result.shortCircuit).toBeTrue();
expect(result.source).toBe('');
});

it('should load in-memory file source with non-zero byteOffset in Uint8Array', async () => {
const target = 'export const sliced = 42;';
const fullBuffer = Buffer.from(`__PADDING__${target}__MORE__`);
const offset = Buffer.byteLength('__PADDING__', 'utf-8');
const length = Buffer.byteLength(target, 'utf-8');
const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length);

initialize({
workspaceRoot,
outputFiles: {
'sliced.mjs': subView,
},
});

const memoryUrl = new URL('./sliced.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const nextLoad = jasmine.createSpy('nextLoad');

const result = await load(rootResolve.url, { format: 'module' }, nextLoad);

expect(nextLoad).not.toHaveBeenCalled();
expect(result.format).toBe('module');
expect(result.shortCircuit).toBeTrue();
expect(result.source).toBe(target);
});

it('should load in-memory file source when outputFiles contain string values', async () => {
initialize({
workspaceRoot,
outputFiles: {
'string-file.mjs': 'export const fromString = 1;',
},
});

const memoryUrl = new URL('./string-file.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const nextLoad = jasmine.createSpy('nextLoad');

const result = await load(rootResolve.url, { format: 'module' }, nextLoad);

expect(nextLoad).not.toHaveBeenCalled();
expect(result.format).toBe('module');
expect(result.shortCircuit).toBeTrue();
expect(result.source).toBe('export const fromString = 1;');
});

it('should reject when in-memory file does not exist in outputFiles', async () => {
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
const nonExistentVirtualUrl = rootResolve.url.replace('main.server.mjs', 'missing.mjs');
const nextLoad = jasmine.createSpy('nextLoad');

await expectAsync(
load(nonExistentVirtualUrl, { format: 'module' }, nextLoad),
).toBeRejectedWithError(/Resolved in-memory ESM file should always exist/);
expect(nextLoad).not.toHaveBeenCalled();
});

it('should delegate to nextLoad for non-angular file URLs', async () => {
const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'module' });
const result = await load(
'file:///workspace/node_modules/rxjs/index.js',
{ format: 'module' },
nextLoad,
);

expect(nextLoad).toHaveBeenCalledWith('file:///workspace/node_modules/rxjs/index.js', {
format: 'module',
});
expect(result.format).toBe('module');
});

it('should delegate to nextLoad for non-memory non-file URLs', async () => {
const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'builtin' });
const result = await load('node:fs', { format: 'builtin' }, nextLoad);

expect(nextLoad).toHaveBeenCalledWith('node:fs');
expect(result.format).toBe('builtin');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,41 @@ import { pathToFileURL } from 'node:url';

export const IMPORT_EXEC_ARGV =
'--import=' + pathToFileURL(join(__dirname, 'register-hooks.js')).href;

/**
* Creates a shared zero-copy `Uint8Array` backed by a `SharedArrayBuffer` for the given file content.
*/
export function createSharedFile(content: string | Uint8Array): Uint8Array {
if (typeof content === 'string') {
const byteLength = Buffer.byteLength(content, 'utf-8');
const sab = new SharedArrayBuffer(byteLength);
Buffer.from(sab).write(content, 'utf-8');

return new Uint8Array(sab);
}

if (content.buffer instanceof SharedArrayBuffer) {
return content;
}

const sab = new SharedArrayBuffer(content.byteLength);
const view = new Uint8Array(sab);
view.set(content);

return view;
}

/**
* Creates shared zero-copy `Uint8Array` views backed by `SharedArrayBuffer` for all output files.
*/
export function createSharedServerFiles(
outputFiles: Record<string, string | Uint8Array>,
): Record<string, Uint8Array> {
const sharedFiles: Record<string, Uint8Array> = {};

for (const [key, value] of Object.entries(outputFiles)) {
sharedFiles[key] = createSharedFile(value);
}

return sharedFiles;
}
Loading