Skip to content

Commit d656aeb

Browse files
authored
Merge pull request #4808 from easyops-cn/steve/v3-app-preview
feat(): support app preview
2 parents 85a05ea + 0c9ae17 commit d656aeb

File tree

6 files changed

+257
-45
lines changed

6 files changed

+257
-45
lines changed

etc/runtime.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ declare namespace __secret_internals {
7474
RenderUseBrickResult,
7575
MountUseBrickResult,
7676
updateSnippetPreviewSettings,
77+
getBrickPackages,
7778
setRealTimeDataInspectRoot,
7879
addRealTimeDataInspectHook,
7980
legacyDoTransform,
@@ -195,6 +196,9 @@ export function getAuth(): object | undefined;
195196
// @public
196197
export function getBasePath(): string;
197198

199+
// @public (undocumented)
200+
function getBrickPackages(): BrickPackage[];
201+
198202
// @public (undocumented)
199203
function getBrickPackagesById(id: string): BrickPackage | undefined;
200204

packages/brick-container/src/bootstrap.ts

Lines changed: 115 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
resetReloadForError,
88
__secret_internals,
99
isNetworkError,
10+
getHistory,
1011
} from "@next-core/runtime";
1112
import { HttpRequestConfig, http } from "@next-core/http";
1213
import { i18n, initializeI18n } from "@next-core/i18n";
@@ -20,6 +21,7 @@ import {
2021
analytics,
2122
} from "@next-core/easyops-runtime";
2223
import "@next-core/theme";
24+
import type { BootstrapData, Storyboard } from "@next-core/types";
2325
import "./XMLHttpRequest.js";
2426
import { loadCheckLogin } from "./loadCheckLogin.js";
2527
import { fulfilStoryboard, loadBootstrapData } from "./loadBootstrapData.js";
@@ -30,6 +32,10 @@ import { getMock } from "./mocks.js";
3032
import { NS, K, locales } from "./i18n.js";
3133
import { DefaultError } from "./DefaultError.js";
3234

35+
const isAppPreview = !!new URLSearchParams(window.location.search).get(
36+
"_experimental_app_preview_"
37+
);
38+
3339
customElements.define("easyops-default-error", DefaultError);
3440

3541
analytics.initialize(
@@ -115,61 +121,126 @@ const requestEnd = (): void => {
115121
window.addEventListener("request.start", requestStart);
116122
window.addEventListener("request.end", requestEnd);
117123

118-
const runtime = createRuntime({
119-
hooks: {
120-
auth,
121-
fulfilStoryboard,
122-
checkPermissions,
123-
flowApi,
124-
checkInstalledApps,
125-
menu,
126-
images: { imagesFactory, widgetImagesFactory },
127-
messageDispatcher,
128-
pageView: analytics.pageView,
129-
},
130-
});
124+
function doCreateRuntime() {
125+
return createRuntime({
126+
hooks: {
127+
auth,
128+
checkPermissions,
129+
flowApi,
130+
checkInstalledApps,
131+
menu,
132+
images: { imagesFactory, widgetImagesFactory },
133+
messageDispatcher,
134+
...(isAppPreview
135+
? null
136+
: {
137+
fulfilStoryboard,
138+
pageView: analytics.pageView,
139+
}),
140+
},
141+
});
142+
}
131143

132144
initializeI18n(NS, locales);
133145

146+
interface PreviewWindow extends Window {
147+
_preview_only_appPreviewer?: AppPreviewer;
148+
}
149+
150+
class AppPreviewer {
151+
#setupCalled = false;
152+
153+
async setup(data: BootstrapData, url: string) {
154+
try {
155+
if (this.#setupCalled) {
156+
throw new Error(
157+
"_preview_only_setupAppPreview can only be called once"
158+
);
159+
}
160+
this.#setupCalled = true;
161+
history.replaceState(null, "", url);
162+
const runtime = doCreateRuntime();
163+
await loadCheckLogin();
164+
await runtime.bootstrap(data);
165+
} catch (error) {
166+
handleError(error);
167+
}
168+
requestEnd();
169+
}
170+
171+
async update(appId: string, storyboard: Partial<Storyboard>) {
172+
__secret_internals.updateStoryboard(appId, storyboard);
173+
}
174+
175+
reload() {
176+
getHistory().reload();
177+
}
178+
179+
push(url: string) {
180+
getHistory().push(url);
181+
}
182+
183+
replace(url: string) {
184+
getHistory().replace(url);
185+
}
186+
187+
goBack() {
188+
getHistory().goBack();
189+
}
190+
191+
goForward() {
192+
getHistory().goForward();
193+
}
194+
}
195+
134196
async function main() {
135197
try {
136-
const [, bootstrapData] = await Promise.all([
137-
loadCheckLogin(),
138-
loadBootstrapData(),
139-
]);
140-
await runtime.bootstrap(bootstrapData);
141-
resetReloadForError();
198+
if (isAppPreview) {
199+
requestStart();
200+
(window as PreviewWindow)._preview_only_appPreviewer = new AppPreviewer();
201+
return "ok";
202+
} else {
203+
const runtime = doCreateRuntime();
204+
const [, bootstrapData] = await Promise.all([
205+
loadCheckLogin(),
206+
loadBootstrapData(),
207+
]);
208+
await runtime.bootstrap(bootstrapData);
209+
resetReloadForError();
210+
}
142211
return "ok";
143212
} catch (error) {
144-
// eslint-disable-next-line no-console
145-
console.error("bootstrap failed:", error);
146-
147-
if (shouldReloadForError(error)) {
148-
location.reload();
149-
return "failed";
150-
}
213+
handleError(error);
214+
return "failed";
215+
}
216+
}
151217

152-
// `.bootstrap-error` makes loading-bar invisible.
153-
document.body.classList.add("bootstrap-error");
154-
155-
const errorElement = document.createElement(
156-
"easyops-default-error"
157-
) as DefaultError;
158-
errorElement.errorTitle = isNetworkError(error)
159-
? i18n.t(`${NS}:${K.NETWORK_ERROR}`)
160-
: i18n.t(`${NS}:${K.BOOTSTRAP_ERROR}`);
161-
errorElement.textContent = httpErrorToString(error);
162-
const linkElement = document.createElement("a");
163-
linkElement.slot = "link";
164-
linkElement.href = location.href;
165-
linkElement.textContent = i18n.t(`${NS}:${K.RELOAD}`);
166-
errorElement.appendChild(linkElement);
167-
168-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
169-
document.querySelector("#main-mount-point")!.replaceChildren(errorElement);
218+
function handleError(error: unknown) {
219+
// eslint-disable-next-line no-console
220+
console.error("bootstrap failed:", error);
170221

222+
if (shouldReloadForError(error)) {
223+
location.reload();
171224
return "failed";
172225
}
226+
227+
// `.bootstrap-error` makes loading-bar invisible.
228+
document.body.classList.add("bootstrap-error");
229+
230+
const errorElement = document.createElement(
231+
"easyops-default-error"
232+
) as DefaultError;
233+
errorElement.errorTitle = isNetworkError(error)
234+
? i18n.t(`${NS}:${K.NETWORK_ERROR}`)
235+
: i18n.t(`${NS}:${K.BOOTSTRAP_ERROR}`);
236+
errorElement.textContent = httpErrorToString(error);
237+
const linkElement = document.createElement("a");
238+
linkElement.slot = "link";
239+
linkElement.href = location.href;
240+
linkElement.textContent = i18n.t(`${NS}:${K.RELOAD}`);
241+
errorElement.appendChild(linkElement);
242+
243+
document.querySelector("#main-mount-point")!.replaceChildren(errorElement);
173244
}
174245

175246
const bootstrapStatus = main();

packages/runtime/src/internal/secret_internals.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ export function getAllContextValues({
365365
return runtimeContext.ctxStore.getAllValues();
366366
}
367367

368+
export { getBrickPackages };
369+
368370
export function getBrickPackagesById(id: string) {
369371
return getBrickPackages().find((pkg) =>
370372
pkg.id ? pkg.id === id : pkg.filePath.startsWith(`${id}/`)

packages/yo/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"node": ">=16"
2222
},
2323
"dependencies": {
24+
"jsonc-parser": "^3.3.1",
2425
"minimist": "^1.2.8",
2526
"plop": "^4.0.1"
2627
},

packages/yo/src/plopfile.js

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import path from "node:path";
22
import { existsSync } from "node:fs";
3-
import { readFile, readdir, writeFile } from "node:fs/promises";
3+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
44
import { fileURLToPath } from "node:url";
5+
import { parse } from "jsonc-parser";
56

67
const __dirname = path.dirname(fileURLToPath(import.meta.url));
78

@@ -123,6 +124,10 @@ export default function (
123124
name: "Create a new shared library",
124125
value: "shared",
125126
},
127+
{
128+
name: "Setup VSCode testing settings",
129+
value: "vscode-testing",
130+
},
126131
],
127132
},
128133
{
@@ -387,6 +392,8 @@ export default function (
387392
templateFiles: "templates/shared",
388393
},
389394
];
395+
} else if (data.type === "vscode-testing") {
396+
return [initVscodeTesting];
390397
}
391398
return [];
392399
},
@@ -396,3 +403,125 @@ export default function (
396403
function getObjectPartialInPackageJson(object) {
397404
return JSON.stringify(object, null, 4).replace(/\}\s*$/, " }");
398405
}
406+
407+
async function initVscodeTesting() {
408+
const folders = await getWorkspaceFolders();
409+
if (folders.length === 0) {
410+
return "No workspace folders found, are you sure this is a monorepo?";
411+
}
412+
413+
const vscodeDir = path.join(rootDir, ".vscode");
414+
if (!existsSync(vscodeDir)) {
415+
await mkdir(vscodeDir);
416+
}
417+
418+
const settingsJson = path.join(vscodeDir, "settings.json");
419+
let settings = {};
420+
if (existsSync(settingsJson)) {
421+
// settings = JSON.parse(await readFile(settingsJson, "utf-8"));
422+
const errors = [];
423+
settings = parse(await readFile(settingsJson, "utf-8"), errors, {
424+
allowTrailingComma: true,
425+
});
426+
if (errors.length > 0) {
427+
return `Failed to parse existing .vscode/settings.json: ${errors
428+
.map((e) => `at offset ${e.offset} (${e.error})`)
429+
.join(", ")}`;
430+
}
431+
}
432+
Object.assign(settings, {
433+
"jest.runMode": "on-demand",
434+
"jest.jestCommandLine": "npx test-next",
435+
"jest.useJest30": true,
436+
"jest.nodeEnv": {
437+
NODE_ENV: "test",
438+
},
439+
"jest.virtualFolders": folders.map((folder) => ({
440+
name: folder,
441+
rootPath: folder,
442+
})),
443+
});
444+
await writeFile(settingsJson, JSON.stringify(settings, null, 2) + "\n");
445+
446+
const launchJson = path.join(vscodeDir, "launch.json");
447+
let launchConfig = {};
448+
if (existsSync(launchJson)) {
449+
const errors = [];
450+
launchConfig = parse(await readFile(launchJson, "utf-8"), errors, {
451+
allowTrailingComma: true,
452+
});
453+
if (errors.length > 0) {
454+
return `Failed to parse existing .vscode/launch.json: ${errors
455+
.map((e) => `at offset ${e.offset} (${e.error})`)
456+
.join(", ")}`;
457+
}
458+
}
459+
const configs = folders.map((folder) => ({
460+
type: "node",
461+
name: `vscode-jest-tests.v2.${folder}`,
462+
request: "launch",
463+
args: [
464+
"--runInBand",
465+
"--watchAll=false",
466+
"--testNamePattern",
467+
"${jest.testNamePattern}",
468+
"--runTestsByPath",
469+
"${jest.testFile}",
470+
],
471+
cwd: `${rootDir}/${folder}`,
472+
console: "integratedTerminal",
473+
internalConsoleOptions: "neverOpen",
474+
program: `${rootDir}/node_modules/.bin/test-next`,
475+
env: {
476+
NODE_ENV: "test",
477+
},
478+
}));
479+
launchConfig.configurations = [
480+
...(launchConfig.configurations?.filter(
481+
(config) => !config.name?.startsWith("vscode-jest-tests.")
482+
) ?? []),
483+
...configs,
484+
];
485+
await writeFile(launchJson, JSON.stringify(launchConfig, null, 2) + "\n");
486+
487+
return 'Updated VSCode testing settings in ".vscode/settings.json" and ".vscode/launch.json".';
488+
}
489+
490+
async function getWorkspaceFolders() {
491+
const { workspaces } = packageJson;
492+
/** @type {string[]} */
493+
const folders = (
494+
await Promise.all(
495+
workspaces.map(async (pattern) => {
496+
const result = [];
497+
if (pattern.endsWith("/*")) {
498+
const folder = pattern.slice(0, -2);
499+
const dir = path.join(rootDir, folder);
500+
const dirs = await readdir(dir, {
501+
withFileTypes: true,
502+
});
503+
for (const d of dirs) {
504+
const pkgJsonPath = path.join(dir, d.name, "package.json");
505+
if (d.isDirectory() && existsSync(pkgJsonPath)) {
506+
const pkg = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
507+
if (pkg.scripts?.test) {
508+
result.push(path.join(folder, d.name));
509+
}
510+
}
511+
}
512+
} else {
513+
const dir = path.join(rootDir, pattern);
514+
const pkgJsonPath = path.join(dir, "package.json");
515+
if (existsSync(pkgJsonPath)) {
516+
const pkg = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
517+
if (pkg.scripts?.test) {
518+
result.push(pattern);
519+
}
520+
}
521+
}
522+
return result;
523+
})
524+
)
525+
).flat();
526+
return folders;
527+
}

0 commit comments

Comments
 (0)