Skip to content

Commit

Permalink
Allow additional entries to be marked as inlined to include their con…
Browse files Browse the repository at this point in the history
…tent in the browser asset manifest
  • Loading branch information
lemonmade committed Oct 26, 2024
1 parent 33cf6c8 commit c421ad9
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 64 deletions.
7 changes: 7 additions & 0 deletions .changeset/pretty-otters-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@quilted/preact-browser': patch
'@quilted/assets': patch
'@quilted/rollup': patch
---

Allow additional entries to be marked as inlined to include their content in the browser asset manifest
34 changes: 24 additions & 10 deletions packages/assets/source/manifest/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ const NORMALIZED_ASSET_BUILD_MANIFEST_CACHE = new WeakMap<
NormalizedAssetBuildManifest
>();

const INTEGRITY_VALUE_REGEXP = /^(sha256|sha384|sha512)-[A-Za-z0-9+/=]{44,}$/;

function normalizeAssetBuildManifest(
manifest: AssetBuildManifest,
): NormalizedAssetBuildManifest {
Expand All @@ -141,18 +143,27 @@ function normalizeAssetBuildManifest(
const scriptAttributes = manifest.attributes?.[2] ?? {};

manifest.assets.forEach((asset, index) => {
const [type, source, integrity, attributes] = asset;
const [type, source, integrityOrContent, attributes] = asset;

const resolvedAsset: Asset = {
source: base + source,
attributes: {},
};

if (integrityOrContent) {
if (INTEGRITY_VALUE_REGEXP.test(integrityOrContent)) {
resolvedAsset.attributes!.integrity = integrityOrContent;
} else {
resolvedAsset.content = integrityOrContent;
}
}

if (type === 1) {
assets.styles.set(index, {
source: base + source,
attributes: {integrity: integrity!, ...styleAttributes, ...attributes},
});
Object.assign(resolvedAsset.attributes!, styleAttributes, attributes);
assets.styles.set(index, resolvedAsset);
} else if (type === 2) {
assets.scripts.set(index, {
source: base + source,
attributes: {integrity: integrity!, ...scriptAttributes, ...attributes},
});
Object.assign(resolvedAsset.attributes!, scriptAttributes, attributes);
assets.scripts.set(index, resolvedAsset);
}
});

Expand Down Expand Up @@ -187,7 +198,10 @@ function createBrowserAssetsEntryFromManifest(
const scripts = new Set<Asset>();

if (entry) {
const entryModuleID = manifest.entries[entry];
// Allow developers to omit the leading ./ from nested entrypoints, so they can pass
// either `entry: 'foo/bar'` or `entry: './foo/bar'` and still get the entry assets
const entryModuleID =
manifest.entries[entry] ?? manifest.entries[`./${entry}`];
const entryModule = entryModuleID
? manifest.modules[entryModuleID]
: undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/assets/source/manifest/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ export interface AssetBuildManifest {
export type AssetBuildAsset = [
type: AssetBuildAssetType,
path: string,
integrity?: string,
integrityOrIntegrity?: string,
attributes?: {textContent: string; [key: string]: string | boolean | number},
];
1 change: 1 addition & 0 deletions packages/assets/source/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface Asset {
source: string;
content?: string;
attributes?: Record<string, string | boolean | number>;
}

Expand Down
22 changes: 13 additions & 9 deletions packages/preact-browser/source/server/components/ScriptAssets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ export function ScriptAssets({

return (
<>
{scripts.map((asset) => (
<script
key={asset.source}
{...(scriptAssetAttributes(asset, {
baseURL,
}) as JSX.HTMLAttributes<HTMLScriptElement>)}
{...rest}
/>
))}
{scripts.map((asset) => {
const props: JSX.HTMLAttributes<HTMLScriptElement> = {};

Object.assign(props, scriptAssetAttributes(asset, {baseURL}), rest);

if (asset.content) {
props.dangerouslySetInnerHTML = {__html: asset.content};
} else {
props.src = asset.source;
}

return <script {...props} />;
})}
</>
);
}
25 changes: 16 additions & 9 deletions packages/preact-browser/source/server/components/StyleAssets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,22 @@ export function StyleAssets({

return (
<>
{styles.map((asset) => (
<link
key={asset.source}
{...(styleAssetAttributes(asset, {
baseURL,
}) as JSX.HTMLAttributes<HTMLLinkElement>)}
{...rest}
/>
))}
{styles.map((asset) => {
const props: JSX.HTMLAttributes<any> = {};

Object.assign(props, styleAssetAttributes(asset, {baseURL}), rest);

if (asset.content) {
return (
<style
{...props}
dangerouslySetInnerHTML={{__html: asset.content}}
/>
);
} else {
return <link {...props} href={asset.source} />;
}
})}
</>
);
}
129 changes: 123 additions & 6 deletions packages/rollup/source/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,79 @@ export interface AppBrowserOptions extends AppBaseOptions {
* The entry module for this browser. This should be an absolute path, or relative
* path from the root directory containing your project. This entry should be the
* browser entrypoint. If you don’t provide a module, Quilt will automatically pick
*
* a default entry module for you, based on the conventions [described here](TODO).
*
* @example './browser.tsx'
*/
entry?: string;

/**
* Instead of providing a single `entry` module, you can use this option to list multiple
* independent entry modules for your browser application. This can be useful if you have
* parts of the codebase you want to load separately from one another; for example, as an
* inline script or style, or assets loaded inside an iframe rendered by the “main” part of
* the application.
*
* Entries should be defined similarly to how you would define the `exports` field in a Node.js
* [`package.json` file](https://nodejs.org/api/packages.html#exports). Keys should be prefixed
* with a `./`, and the key names can then be passed to Quilt’s `BrowserAssets` object to retrieve
* the built asset names on your server. The values should be relative paths to the source file
* that acts as the entrypoint for that asset. Entrypoints can be either JavaScript or CSS files.
*
* When using this option, you can override the “main” entrypoint by setting one of the keys
* of this object to the string: `'.'`. You can do this in addition to providing additional
* entry modules, but if you don’t provide the main entrypoint, a default will be used.
*
* @example
* ```js
* quiltApp({
* browser: {
* entry: {
* '.': './main.tsx',
* './inline.css': './inline.css',
* },
* },
* })
* ```
*
* To inline an asset, you can set the value to an object with a `source` property (for the relative
* path) and an `inline` option set to `true`. Note that this option will not inline dependencies of
* the entry into the asset manifest, so make sure that the way you load these scripts accounts for
* the fact that any dependencies will be loaded as external scripts. Because the whole file’s contents
* will be inlined into the asset manifest and server bundles, you should also be careful to only
* inline small JavaScript and CSS assets.
*
* @example
* ```js
* quiltApp({
* browser: {
* entry: {
* '.': './main.tsx',
* './inline.js': {source: './inline.tsx', inline: true},
* },
* },
* })
* ```
*/
entries?: Record<
string,
| string
| {
/**
* The relative path to the source file that acts as the entrypoint for this asset.
*/
source: string;

/**
* Whether to inline the asset into the asset manifest, so that it can be used as an
* inline script or style when rendering HTML content on the server.
*
* @default false
*/
inline?: boolean;
}
>;

/**
* Customizes the magic `quilt:module/entry` module, which can be used as a "magic"
* entry for your application.
Expand Down Expand Up @@ -462,6 +529,7 @@ export async function quiltAppBrowserPlugins({
root = process.cwd(),
app,
entry,
entries,
env,
assets,
module,
Expand Down Expand Up @@ -496,6 +564,7 @@ export async function quiltAppBrowserPlugins({
{workers},
{esnext},
nodePlugins,
supportsESM,
supportsModuleWorkers,
] = await Promise.all([
import('rollup-plugin-visualizer'),
Expand All @@ -514,13 +583,13 @@ export async function quiltAppBrowserPlugins({
bundle: true,
resolve: {exportConditions: ['browser']},
}),
targetsSupportModules(browserGroup.browsers),
targetsSupportModuleWebWorkers(browserGroup.browsers),
]);

const plugins: InputPluginOption[] = [
quiltAppBrowserInput({root: project.root, entry}),
quiltAppBrowserInput({root: project.root, entry, entries}),
...nodePlugins,
systemJS({minify}),
replaceProcessEnv({mode}),
magicModuleEnv({...resolveEnvOption(env), mode, root: project.root}),
magicModuleAppComponent({entry: app, root: project.root}),
Expand Down Expand Up @@ -577,6 +646,10 @@ export async function quiltAppBrowserPlugins({
monorepoPackageAliases({root: project.root}),
];

if (!supportsESM) {
plugins.push(systemJS({minify}));
}

if (assets?.clean ?? true) {
plugins.push(
removeBuildFiles(
Expand Down Expand Up @@ -612,12 +685,27 @@ export async function quiltAppBrowserPlugins({
cacheKey.set('browserGroup', browserGroup.name);
}

// Always inline the system.js entry, since we’ll load it as an inline script to avoid
// an unnecessary JS network waterfall for loading critical, SystemJS-dependent code.
// We’ll also add any additional entry that was passed via the `entries` option that is
// marked as being `inline`.
const inline = new Set(['system.js']);

if (entries) {
for (const [name, entry] of Object.entries(entries)) {
if (typeof entry === 'object' && entry.inline) {
inline.add(name.startsWith('./') ? name.slice(2) : name);
}
}
}

plugins.push(
assetManifest({
key: cacheKey,
base: baseURL,
file: path.join(manifestsDirectory, `assets${targetFilenamePart}.json`),
priority: assets?.priority,
inline,
}),
visualizer({
template: 'treemap',
Expand All @@ -636,21 +724,28 @@ export async function quiltAppBrowserPlugins({
export function quiltAppBrowserInput({
root,
entry,
}: Pick<AppBrowserOptions, 'root' | 'entry'> = {}) {
entries,
}: Pick<AppBrowserOptions, 'root' | 'entry' | 'entries'> = {}) {
const MODULES_TO_ENTRIES = new Map<string, string>();

return {
name: '@quilted/app-browser/input',
async options(options) {
const finalEntry =
normalizeRollupInput(options.input) ??
(await sourceEntryForAppBrowser({entry, root})) ??
(await sourceEntryForAppBrowser({
entry: entry ?? getSourceFromCustomEntry(entries?.['.']),
root,
})) ??
MAGIC_MODULE_ENTRY;
const finalEntryName =
typeof finalEntry === 'string' && finalEntry !== MAGIC_MODULE_ENTRY
? path.basename(finalEntry).split('.').slice(0, -1).join('.')
: 'browser';
const additionalEntries = await additionalEntriesForAppBrowser({root});
const additionalEntries = await additionalEntriesForAppBrowser({
root,
entries,
});

if (typeof finalEntry === 'string') {
MODULES_TO_ENTRIES.set(finalEntry, '.');
Expand Down Expand Up @@ -1399,8 +1494,10 @@ const SERVER_EXPORT_CONDITIONS = new Set([
]);

export async function additionalEntriesForAppBrowser({
entries,
root = process.cwd(),
}: {
entries?: AppBrowserOptions['entries'];
root?: string | URL;
}) {
const additionalEntries: Record<string, string> = {};
Expand Down Expand Up @@ -1428,6 +1525,20 @@ export async function additionalEntriesForAppBrowser({
}
}

if (entries) {
for (const [key, value] of Object.entries(entries)) {
if (key === '.') continue;

const name = key.startsWith('./') ? key.slice(2) : key;

if (typeof value === 'string') {
additionalEntries[name] = project.resolve(value);
} else {
additionalEntries[name] = project.resolve(value.source);
}
}
}

return additionalEntries;
}

Expand Down Expand Up @@ -1611,3 +1722,9 @@ function createManualChunksSorter(): GetManualChunk {
return `${bundleBaseName}-${relativeId.split(path.sep)[0]?.split('.')[0]}`;
};
}

function getSourceFromCustomEntry(
entry?: string | {source: string; inline?: boolean},
) {
return typeof entry === 'object' ? entry.source : entry;
}
Loading

0 comments on commit c421ad9

Please sign in to comment.