diff --git a/.changeset/dull-candles-brake.md b/.changeset/dull-candles-brake.md
new file mode 100644
index 0000000..85e4911
--- /dev/null
+++ b/.changeset/dull-candles-brake.md
@@ -0,0 +1,5 @@
+---
+"@marko/vite": patch
+---
+
+Add store option for linked mode builds
diff --git a/README.md b/README.md
index 0e66612..4ecd100 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,26 @@ marko({ runtimeId: "MY_MARKO_RUNTIME_ID" });
Set this to `false` to opt out of [linked mode](#linked-mode). When this is false, the plugin will only handle resolving and transforming `.marko` files.
+### options.store
+
+Storage mechanism to preserve data between SSR and client builds when building in linked mode. Two implementations are available:
+
+- FileStore _(default)_
+
+ ```js
+ import { FileStore } from "@marko/vite";
+ const store = new FileStore();
+ ```
+
+ Reads/writes data to the file system. Use this when running the SSR and client builds in seperate processes such as when using Vite from the command line or npm scripts.
+
+- MemoryStore
+ ```js
+ import { MemoryStore } from "@marko/vite";
+ const store = new MemoryStore();
+ ```
+ Reads/writes data to memory. This option can be used when building with Vite programatically.
+
## Code of Conduct
This project adheres to the [eBay Code of Conduct](./.github/CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.loading.0.html b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.loading.0.html
new file mode 100644
index 0000000..1d8a1c2
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.loading.0.html
@@ -0,0 +1,9 @@
+
+
+ Mounted: false Clicks: 0
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.loading.1.html b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.loading.1.html
new file mode 100644
index 0000000..3f9e475
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.loading.1.html
@@ -0,0 +1,9 @@
+
+
+ Mounted: true Clicks: 0
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.step-0.0.html b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.step-0.0.html
new file mode 100644
index 0000000..0345f73
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/build.expected.step-0.0.html
@@ -0,0 +1,9 @@
+
+
+ Mounted: true Clicks: 1
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.loading.0.html b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.loading.0.html
new file mode 100644
index 0000000..1d8a1c2
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.loading.0.html
@@ -0,0 +1,9 @@
+
+
+ Mounted: false Clicks: 0
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.loading.1.html b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.loading.1.html
new file mode 100644
index 0000000..3f9e475
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.loading.1.html
@@ -0,0 +1,9 @@
+
+
+ Mounted: true Clicks: 0
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.step-0.0.html b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.step-0.0.html
new file mode 100644
index 0000000..0345f73
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/__snapshots__/dev.expected.step-0.0.html
@@ -0,0 +1,9 @@
+
+
+ Mounted: true Clicks: 1
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/dev-server.js b/src/__tests__/fixtures/isomorphic-memory-store/dev-server.js
new file mode 100644
index 0000000..ecab68d
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/dev-server.js
@@ -0,0 +1,33 @@
+// In dev we'll start a Vite dev server in middleware mode,
+// and forward requests to our http request handler.
+
+const { createServer } = require("vite");
+const { join } = require("path");
+const markoPlugin = require("../../..").default;
+
+module.exports = (async () => {
+ const devServer = await createServer({
+ root: __dirname,
+ appType: "custom",
+ logLevel: "silent",
+ plugins: [markoPlugin()],
+ optimizeDeps: { force: true },
+ server: {
+ middlewareMode: true,
+ watch: {
+ ignored: ["**/node_modules/**", "**/dist/**", "**/__snapshots__/**"],
+ },
+ },
+ });
+ return devServer.middlewares.use(async (req, res, next) => {
+ try {
+ const { handler } = await devServer.ssrLoadModule(
+ join(__dirname, "./src/index.js")
+ );
+ await handler(req, res, next);
+ } catch (err) {
+ devServer.ssrFixStacktrace(err);
+ return next(err);
+ }
+ });
+})();
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/server.js b/src/__tests__/fixtures/isomorphic-memory-store/server.js
new file mode 100644
index 0000000..ad89b47
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/server.js
@@ -0,0 +1,11 @@
+// In production, simply start up the http server.
+const path = require("path");
+const { createServer } = require("http");
+const serve = require("serve-handler");
+const { handler } = require("./dist/index.mjs");
+const serveOpts = { public: path.resolve(__dirname, "dist") };
+module.exports = createServer(async (req, res) => {
+ await handler(req, res);
+ if (res.headersSent) return;
+ await serve(req, res, serveOpts);
+});
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/src/components/class-component.marko b/src/__tests__/fixtures/isomorphic-memory-store/src/components/class-component.marko
new file mode 100644
index 0000000..9579382
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/src/components/class-component.marko
@@ -0,0 +1,20 @@
+class {
+ onCreate() {
+ this.state = {
+ clickCount: 0,
+ mounted: false
+ };
+ }
+ onMount() {
+ this.state.mounted = true;
+ }
+
+ handleClick() {
+ this.state.clickCount++;
+ }
+}
+
+
+ Mounted: ${state.mounted}
+ Clicks: ${state.clickCount}
+
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/src/components/implicit-component.marko b/src/__tests__/fixtures/isomorphic-memory-store/src/components/implicit-component.marko
new file mode 100644
index 0000000..7d8310b
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/src/components/implicit-component.marko
@@ -0,0 +1,9 @@
+static {
+ if (typeof window === "object") {
+ document.body.firstElementChild.append("Loaded Implicit Component");
+ }
+}
+
+
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/src/components/layout-component.marko b/src/__tests__/fixtures/isomorphic-memory-store/src/components/layout-component.marko
new file mode 100644
index 0000000..8d60022
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/src/components/layout-component.marko
@@ -0,0 +1,15 @@
+static {
+ if (typeof window === "object") {
+ document.body.firstElementChild.append("Loaded Layout Component");
+ }
+}
+
+
+
+
+ Hello World
+
+
+ <${input.renderBody}/>
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/src/index.js b/src/__tests__/fixtures/isomorphic-memory-store/src/index.js
new file mode 100644
index 0000000..d3f5422
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/src/index.js
@@ -0,0 +1,9 @@
+import template from "./template.marko";
+
+export function handler(req, res) {
+ if (req.url === "/") {
+ res.statusCode = 200;
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ template.render({}, res);
+ }
+}
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/src/template.marko b/src/__tests__/fixtures/isomorphic-memory-store/src/template.marko
new file mode 100644
index 0000000..2c5af41
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/src/template.marko
@@ -0,0 +1,9 @@
+style {
+ div { color: green }
+}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/__tests__/fixtures/isomorphic-memory-store/test.config.ts b/src/__tests__/fixtures/isomorphic-memory-store/test.config.ts
new file mode 100644
index 0000000..fe343ac
--- /dev/null
+++ b/src/__tests__/fixtures/isomorphic-memory-store/test.config.ts
@@ -0,0 +1,9 @@
+import { MemoryStore, type Options } from "../../..";
+
+export const ssr = true;
+export async function steps() {
+ await page.click("#clickable");
+}
+export const options: Options = {
+ store: new MemoryStore(),
+};
diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts
index dfd1168..b06ba85 100644
--- a/src/__tests__/main.test.ts
+++ b/src/__tests__/main.test.ts
@@ -10,7 +10,7 @@ import { JSDOM } from "jsdom";
import { createRequire } from "module";
import * as playwright from "playwright";
import { defaultNormalizer, defaultSerializer } from "@marko/fixture-snapshots";
-import markoPlugin from "..";
+import markoPlugin, { type Options } from "..";
declare global {
const page: playwright.Page;
@@ -84,6 +84,7 @@ for (const fixture of fs.readdirSync(FIXTURES)) {
const config = requireCwd(path.join(dir, "test.config.ts")) as {
ssr: boolean;
steps?: Step | Step[];
+ options?: Options;
};
const steps: Step[] = config.steps
? Array.isArray(config.steps)
@@ -105,7 +106,7 @@ for (const fixture of fs.readdirSync(FIXTURES)) {
root: dir,
configFile: false,
logLevel: "silent",
- plugins: [markoPlugin()],
+ plugins: [markoPlugin(config.options)],
build: {
write: true,
minify: false,
@@ -118,7 +119,7 @@ for (const fixture of fs.readdirSync(FIXTURES)) {
root: dir,
configFile: false,
logLevel: "silent",
- plugins: [markoPlugin()],
+ plugins: [markoPlugin(config.options)],
build: {
write: true,
minify: false,
@@ -148,7 +149,7 @@ for (const fixture of fs.readdirSync(FIXTURES)) {
},
logLevel: "silent",
optimizeDeps: { force: true },
- plugins: [markoPlugin({ linked: false })],
+ plugins: [markoPlugin({ ...config.options, linked: false })],
});
devServer.listen(await getAvailablePort());
@@ -161,7 +162,7 @@ for (const fixture of fs.readdirSync(FIXTURES)) {
root: dir,
configFile: false,
logLevel: "silent",
- plugins: [markoPlugin({ linked: false })],
+ plugins: [markoPlugin({ ...config.options, linked: false })],
build: {
write: true,
minify: false,
diff --git a/src/index.ts b/src/index.ts
index 19fa111..d8a0b75 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,7 +1,6 @@
import type * as vite from "vite";
import type * as Compiler from "@marko/compiler";
-import os from "os";
import fs from "fs";
import path from "path";
import crypto from "crypto";
@@ -16,6 +15,10 @@ import {
DocManifest,
} from "./manifest-generator";
import esbuildPlugin from "./esbuild-plugin";
+import { BuildStore, FileStore } from "./store";
+
+export * from "./store";
+export type { BuildStore } from "./store";
export interface Options {
// Defaults to true, set to false to disable automatic component discovery and hydration.
@@ -28,6 +31,8 @@ export interface Options {
translator?: string;
// Overrides the Babel config that Marko will use.
babelConfig?: Compiler.Config["babelConfig"];
+ // Store to use between SSR and client builds, defaults to file system
+ store?: BuildStore;
}
interface BrowserManifest {
@@ -53,13 +58,13 @@ const queryReg = /\?marko-.+$/;
const browserEntryQuery = "?marko-browser-entry";
const serverEntryQuery = "?marko-server-entry";
const virtualFileQuery = "?marko-virtual";
+const manifestFileName = "manifest.json";
const markoExt = ".marko";
const htmlExt = ".html";
const resolveOpts = { skipSelf: true };
const cache = new Map();
const thisFile =
typeof __filename === "string" ? __filename : fileURLToPath(import.meta.url);
-let tempDir: Promise | undefined;
export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
let compiler: typeof Compiler;
@@ -128,6 +133,7 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
let devServer: vite.ViteDevServer;
let registeredTag: string | false = false;
let serverManifest: ServerManifest | undefined;
+ let store: BuildStore;
const entrySources = new Map();
const transformWatchFiles = new Map();
const transformOptionalFiles = new Map();
@@ -144,6 +150,11 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
devEntryFile = path.join(root, "index.html");
isBuild = env.command === "build";
isSSRBuild = isBuild && linked && Boolean(config.build!.ssr);
+ store =
+ opts.store ||
+ new FileStore(
+ `marko-vite-${crypto.createHash("SHA1").update(root).digest("hex")}`
+ );
if (linked && !registeredTag) {
// Here we inject either the watchMode vite tag, or the build one.
@@ -237,12 +248,12 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
},
async buildStart(inputOptions) {
if (isBuild && linked && !isSSRBuild) {
- const serverMetaFile = await getServerManifestFile(root);
- this.addWatchFile(serverMetaFile);
+ // Is this needed?
+ //this.addWatchFile(serverMetaFile);
try {
serverManifest = JSON.parse(
- await fs.promises.readFile(serverMetaFile, "utf-8")
+ (await store.get(manifestFileName))!
) as ServerManifest;
inputOptions.input = toHTMLEntries(root, serverManifest.entries);
for (const entry in serverManifest.entrySources) {
@@ -489,15 +500,12 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
}
}
- await fs.promises.writeFile(
- await getServerManifestFile(root),
- JSON.stringify(serverManifest)
- );
+ await store.set(manifestFileName, JSON.stringify(serverManifest));
} else {
const browserManifest: BrowserManifest = {};
- for (const entryId in serverManifest.entries) {
- const fileName = serverManifest.entries[entryId];
+ for (const entryId in serverManifest!.entries) {
+ const fileName = serverManifest!.entries[entryId];
let chunkId = fileName + htmlExt;
let chunk = bundle[chunkId];
@@ -525,7 +533,7 @@ export default function markoPlugin(opts: Options = {}): vite.Plugin[] {
browserManifest
)};\n`;
- for (const fileName of serverManifest.chunksNeedingAssets) {
+ for (const fileName of serverManifest!.chunksNeedingAssets) {
await fs.promises.appendFile(fileName, manifestStr);
}
}
@@ -557,35 +565,6 @@ function toHTMLEntries(root: string, serverEntries: ServerManifest["entries"]) {
return result;
}
-async function getServerManifestFile(root: string) {
- return path.join(await getTempDir(root), "manifest.json");
-}
-
-function getTempDir(root: string) {
- return (
- tempDir ||
- (tempDir = (async () => {
- const dir = path.join(
- os.tmpdir(),
- `marko-vite-${crypto.createHash("SHA1").update(root).digest("hex")}`
- );
-
- try {
- const stat = await fs.promises.stat(dir);
-
- if (stat.isDirectory()) {
- return dir;
- }
- } catch {
- await fs.promises.mkdir(dir);
- return dir;
- }
-
- throw new Error("Unable to create temp directory");
- })())
- );
-}
-
function toEntryId(id: string) {
const lastSepIndex = id.lastIndexOf(path.sep);
let name = id.slice(lastSepIndex + 1, id.indexOf(".", lastSepIndex));
diff --git a/src/store/__tests__/file-store.test.ts b/src/store/__tests__/file-store.test.ts
new file mode 100644
index 0000000..eb14722
--- /dev/null
+++ b/src/store/__tests__/file-store.test.ts
@@ -0,0 +1,56 @@
+import fs from "fs";
+import assert from "assert";
+import FileStore from "../file-store";
+
+let store: FileStore;
+beforeEach(() => {
+ store = new FileStore("test");
+});
+
+afterEach(async () => {
+ try {
+ const dir = await store._temp;
+ if (dir) {
+ await fs.promises.rm(dir, { recursive: true, force: true });
+ }
+ } catch (err) {
+ // do nothing
+ }
+});
+
+describe("file store should", () => {
+ it("allow retrieving data", async () => {
+ const expectedKey = "hello";
+ const expectedData = "world";
+
+ await store.set(expectedKey, expectedData);
+
+ assert.equal(await store.get(expectedKey), expectedData);
+ assert.equal(await store.get("non-existing key"), undefined);
+ });
+
+ it("allow checking for existence of data", async () => {
+ const expectedKey = "hello";
+
+ await store.set(expectedKey, "");
+
+ assert.equal(await store.has(expectedKey), true);
+ assert.equal(await store.has("non-existing key"), false);
+ });
+
+ it("throw when unable to create a temp directory", async () => {
+ const mkdir = fs.promises.mkdir;
+ fs.promises.mkdir = () => {
+ throw new Error("Fake `fs.promises.mkdir` always throws");
+ };
+
+ try {
+ await store.set("hello", "world");
+ assert.fail();
+ } catch (err) {
+ fs.promises.mkdir = mkdir;
+ } finally {
+ fs.promises.mkdir = mkdir;
+ }
+ });
+});
diff --git a/src/store/__tests__/memory-store.test.ts b/src/store/__tests__/memory-store.test.ts
new file mode 100644
index 0000000..003ff9f
--- /dev/null
+++ b/src/store/__tests__/memory-store.test.ts
@@ -0,0 +1,28 @@
+import assert from "assert";
+import MemoryStore from "../memory-store";
+
+let store: MemoryStore;
+beforeEach(() => {
+ store = new MemoryStore();
+});
+
+describe("memory store should", () => {
+ it("allow retrieving data", async () => {
+ const expectedKey = "hello";
+ const expectedData = "world";
+
+ await store.set(expectedKey, expectedData);
+
+ assert.equal(await store.get(expectedKey), expectedData);
+ assert.equal(await store.get("non-existing key"), undefined);
+ });
+
+ it("allow checking for existence of data", async () => {
+ const expectedKey = "hello";
+
+ await store.set(expectedKey, "");
+
+ assert.equal(await store.has(expectedKey), true);
+ assert.equal(await store.has("non-existing key"), false);
+ });
+});
diff --git a/src/store/file-store.ts b/src/store/file-store.ts
new file mode 100644
index 0000000..d686bf2
--- /dev/null
+++ b/src/store/file-store.ts
@@ -0,0 +1,63 @@
+import path from "path";
+import fs from "fs";
+import os from "os";
+import type { BuildStore } from "./types";
+
+export default class FileStore implements BuildStore {
+ _id: string;
+ _temp: Promise | undefined;
+ _cache: Map;
+ constructor(id: string) {
+ this._id = id;
+ this._cache = new Map();
+ }
+ async _getKeyPath(key: string) {
+ this._temp ??= getTempDir(this._id);
+ return path.join(await this._temp, key);
+ }
+ async has(key: string) {
+ if (!this._cache.has(key)) {
+ const path = await this._getKeyPath(key);
+ try {
+ await fs.promises.access(path);
+ } catch (e) {
+ return false;
+ }
+ }
+ return true;
+ }
+ async get(key: string) {
+ let value = this._cache.get(key);
+ if (value === undefined) {
+ const path = await this._getKeyPath(key);
+ try {
+ value = await fs.promises.readFile(path, "utf-8");
+ } catch (e) {
+ return undefined;
+ }
+ this._cache.set(key, value);
+ }
+ return value;
+ }
+ async set(key: string, value: string) {
+ this._cache.set(key, value);
+ const path = await this._getKeyPath(key);
+ await fs.promises.writeFile(path, value, "utf-8");
+ }
+}
+
+async function getTempDir(id: string) {
+ const dir = path.join(os.tmpdir(), id);
+
+ try {
+ const stat = await fs.promises.stat(dir);
+ if (stat.isDirectory()) {
+ return dir;
+ }
+ } catch {
+ await fs.promises.mkdir(dir);
+ return dir;
+ }
+
+ throw new Error("Unable to create temp directory");
+}
diff --git a/src/store/index.ts b/src/store/index.ts
new file mode 100644
index 0000000..18672da
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1,3 @@
+export { default as FileStore } from "./file-store";
+export { default as MemoryStore } from "./memory-store";
+export type { BuildStore } from "./types";
diff --git a/src/store/memory-store.ts b/src/store/memory-store.ts
new file mode 100644
index 0000000..c657fc5
--- /dev/null
+++ b/src/store/memory-store.ts
@@ -0,0 +1,17 @@
+import type { BuildStore } from "./types";
+
+export default class MemoryStore implements BuildStore {
+ _store: Map;
+ constructor() {
+ this._store = new Map();
+ }
+ async has(key: string) {
+ return Promise.resolve(this._store.has(key));
+ }
+ async get(key: string) {
+ return Promise.resolve(this._store.get(key));
+ }
+ async set(key: string, value: string) {
+ this._store.set(key, value);
+ }
+}
diff --git a/src/store/types.ts b/src/store/types.ts
new file mode 100644
index 0000000..85daac2
--- /dev/null
+++ b/src/store/types.ts
@@ -0,0 +1,5 @@
+export interface BuildStore {
+ has(key: string): Promise;
+ get(key: string): Promise;
+ set(key: string, value: string): Promise;
+}