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
4 changes: 4 additions & 0 deletions etc/runtime.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ declare namespace __secret_internals {
RenderUseBrickResult,
MountUseBrickResult,
updateSnippetPreviewSettings,
getBrickPackages,
setRealTimeDataInspectRoot,
addRealTimeDataInspectHook,
legacyDoTransform,
Expand Down Expand Up @@ -195,6 +196,9 @@ export function getAuth(): object | undefined;
// @public
export function getBasePath(): string;

// @public (undocumented)
function getBrickPackages(): BrickPackage[];

// @public (undocumented)
function getBrickPackagesById(id: string): BrickPackage | undefined;

Expand Down
159 changes: 115 additions & 44 deletions packages/brick-container/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resetReloadForError,
__secret_internals,
isNetworkError,
getHistory,
} from "@next-core/runtime";
import { HttpRequestConfig, http } from "@next-core/http";
import { i18n, initializeI18n } from "@next-core/i18n";
Expand All @@ -20,6 +21,7 @@ import {
analytics,
} from "@next-core/easyops-runtime";
import "@next-core/theme";
import type { BootstrapData, Storyboard } from "@next-core/types";
import "./XMLHttpRequest.js";
import { loadCheckLogin } from "./loadCheckLogin.js";
import { fulfilStoryboard, loadBootstrapData } from "./loadBootstrapData.js";
Expand All @@ -30,6 +32,10 @@ import { getMock } from "./mocks.js";
import { NS, K, locales } from "./i18n.js";
import { DefaultError } from "./DefaultError.js";

const isAppPreview = !!new URLSearchParams(window.location.search).get(
"_experimental_app_preview_"
);

customElements.define("easyops-default-error", DefaultError);

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

const runtime = createRuntime({
hooks: {
auth,
fulfilStoryboard,
checkPermissions,
flowApi,
checkInstalledApps,
menu,
images: { imagesFactory, widgetImagesFactory },
messageDispatcher,
pageView: analytics.pageView,
},
});
function doCreateRuntime() {
return createRuntime({
hooks: {
auth,
checkPermissions,
flowApi,
checkInstalledApps,
menu,
images: { imagesFactory, widgetImagesFactory },
messageDispatcher,
...(isAppPreview
? null
: {
fulfilStoryboard,
pageView: analytics.pageView,
}),
},
});
Comment on lines +124 to +141
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

修复 ...null 展开导致的运行时异常

_experimental_app_preview_ 参数存在时,isAppPreviewtrue,表达式 ...(isAppPreview ? null : { … }) 会在对象字面量内尝试展开 null,从而抛出 TypeError: Cannot convert undefined or null to object,预览模式在创建 runtime 之前即崩溃。请将条件分支改为展开空对象,或在展开前先行判空。

-      ...(isAppPreview
-        ? null
-        : {
-            fulfilStoryboard,
-            pageView: analytics.pageView,
-          }),
+      ...(isAppPreview
+        ? {}
+        : {
+            fulfilStoryboard,
+            pageView: analytics.pageView,
+          }),
🤖 Prompt for AI Agents
In packages/brick-container/src/bootstrap.ts around lines 124 to 141, the object
spread uses ...(isAppPreview ? null : { … }) which throws when isAppPreview is
true because null cannot be spread; replace the conditional to spread an empty
object instead (e.g. ...(isAppPreview ? {} : { fulfilStoryboard, pageView:
analytics.pageView })) or restructure to conditionally add the properties only
when isAppPreview is false, ensuring no null/undefined is ever passed to the
object spread.

}

initializeI18n(NS, locales);

interface PreviewWindow extends Window {
_preview_only_appPreviewer?: AppPreviewer;
}

class AppPreviewer {
#setupCalled = false;

async setup(data: BootstrapData, url: string) {
try {
if (this.#setupCalled) {
throw new Error(
"_preview_only_setupAppPreview can only be called once"
);
}
this.#setupCalled = true;
history.replaceState(null, "", url);
const runtime = doCreateRuntime();
await loadCheckLogin();
await runtime.bootstrap(data);
} catch (error) {
handleError(error);
}
requestEnd();
}

async update(appId: string, storyboard: Partial<Storyboard>) {
__secret_internals.updateStoryboard(appId, storyboard);
}

reload() {
getHistory().reload();
}

push(url: string) {
getHistory().push(url);
}

replace(url: string) {
getHistory().replace(url);
}

goBack() {
getHistory().goBack();
}

goForward() {
getHistory().goForward();
}
}

async function main() {
try {
const [, bootstrapData] = await Promise.all([
loadCheckLogin(),
loadBootstrapData(),
]);
await runtime.bootstrap(bootstrapData);
resetReloadForError();
if (isAppPreview) {
requestStart();
(window as PreviewWindow)._preview_only_appPreviewer = new AppPreviewer();
return "ok";
} else {
const runtime = doCreateRuntime();
const [, bootstrapData] = await Promise.all([
loadCheckLogin(),
loadBootstrapData(),
]);
await runtime.bootstrap(bootstrapData);
resetReloadForError();
}
return "ok";
} catch (error) {
// eslint-disable-next-line no-console
console.error("bootstrap failed:", error);

if (shouldReloadForError(error)) {
location.reload();
return "failed";
}
handleError(error);
return "failed";
}
}

// `.bootstrap-error` makes loading-bar invisible.
document.body.classList.add("bootstrap-error");

const errorElement = document.createElement(
"easyops-default-error"
) as DefaultError;
errorElement.errorTitle = isNetworkError(error)
? i18n.t(`${NS}:${K.NETWORK_ERROR}`)
: i18n.t(`${NS}:${K.BOOTSTRAP_ERROR}`);
errorElement.textContent = httpErrorToString(error);
const linkElement = document.createElement("a");
linkElement.slot = "link";
linkElement.href = location.href;
linkElement.textContent = i18n.t(`${NS}:${K.RELOAD}`);
errorElement.appendChild(linkElement);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
document.querySelector("#main-mount-point")!.replaceChildren(errorElement);
function handleError(error: unknown) {
// eslint-disable-next-line no-console
console.error("bootstrap failed:", error);

if (shouldReloadForError(error)) {
location.reload();
return "failed";
}

// `.bootstrap-error` makes loading-bar invisible.
document.body.classList.add("bootstrap-error");

const errorElement = document.createElement(
"easyops-default-error"
) as DefaultError;
errorElement.errorTitle = isNetworkError(error)
? i18n.t(`${NS}:${K.NETWORK_ERROR}`)
: i18n.t(`${NS}:${K.BOOTSTRAP_ERROR}`);
errorElement.textContent = httpErrorToString(error);
const linkElement = document.createElement("a");
linkElement.slot = "link";
linkElement.href = location.href;
linkElement.textContent = i18n.t(`${NS}:${K.RELOAD}`);
errorElement.appendChild(linkElement);

document.querySelector("#main-mount-point")!.replaceChildren(errorElement);
}

const bootstrapStatus = main();
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime/src/internal/secret_internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ export function getAllContextValues({
return runtimeContext.ctxStore.getAllValues();
}

export { getBrickPackages };

export function getBrickPackagesById(id: string) {
return getBrickPackages().find((pkg) =>
pkg.id ? pkg.id === id : pkg.filePath.startsWith(`${id}/`)
Expand Down
1 change: 1 addition & 0 deletions packages/yo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"node": ">=16"
},
"dependencies": {
"jsonc-parser": "^3.3.1",
"minimist": "^1.2.8",
"plop": "^4.0.1"
},
Expand Down
131 changes: 130 additions & 1 deletion packages/yo/src/plopfile.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { parse } from "jsonc-parser";

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

Expand Down Expand Up @@ -123,6 +124,10 @@ export default function (
name: "Create a new shared library",
value: "shared",
},
{
name: "Setup VSCode testing settings",
value: "vscode-testing",
},
],
},
{
Expand Down Expand Up @@ -387,6 +392,8 @@ export default function (
templateFiles: "templates/shared",
},
];
} else if (data.type === "vscode-testing") {
return [initVscodeTesting];
}
return [];
},
Expand All @@ -396,3 +403,125 @@ export default function (
function getObjectPartialInPackageJson(object) {
return JSON.stringify(object, null, 4).replace(/\}\s*$/, " }");
}

async function initVscodeTesting() {
const folders = await getWorkspaceFolders();
if (folders.length === 0) {
return "No workspace folders found, are you sure this is a monorepo?";
}

const vscodeDir = path.join(rootDir, ".vscode");
if (!existsSync(vscodeDir)) {
await mkdir(vscodeDir);
}

const settingsJson = path.join(vscodeDir, "settings.json");
let settings = {};
if (existsSync(settingsJson)) {
// settings = JSON.parse(await readFile(settingsJson, "utf-8"));
const errors = [];
settings = parse(await readFile(settingsJson, "utf-8"), errors, {
allowTrailingComma: true,
});
if (errors.length > 0) {
return `Failed to parse existing .vscode/settings.json: ${errors
.map((e) => `at offset ${e.offset} (${e.error})`)
.join(", ")}`;
}
}
Object.assign(settings, {
"jest.runMode": "on-demand",
"jest.jestCommandLine": "npx test-next",
"jest.useJest30": true,
"jest.nodeEnv": {
NODE_ENV: "test",
},
"jest.virtualFolders": folders.map((folder) => ({
name: folder,
rootPath: folder,
})),
});
await writeFile(settingsJson, JSON.stringify(settings, null, 2) + "\n");

const launchJson = path.join(vscodeDir, "launch.json");
let launchConfig = {};
if (existsSync(launchJson)) {
const errors = [];
launchConfig = parse(await readFile(launchJson, "utf-8"), errors, {
allowTrailingComma: true,
});
if (errors.length > 0) {
return `Failed to parse existing .vscode/launch.json: ${errors
.map((e) => `at offset ${e.offset} (${e.error})`)
.join(", ")}`;
}
}
const configs = folders.map((folder) => ({
type: "node",
name: `vscode-jest-tests.v2.${folder}`,
request: "launch",
args: [
"--runInBand",
"--watchAll=false",
"--testNamePattern",
"${jest.testNamePattern}",
"--runTestsByPath",
"${jest.testFile}",
],
cwd: `${rootDir}/${folder}`,
console: "integratedTerminal",
internalConsoleOptions: "neverOpen",
program: `${rootDir}/node_modules/.bin/test-next`,
env: {
NODE_ENV: "test",
},
}));
launchConfig.configurations = [
...(launchConfig.configurations?.filter(
(config) => !config.name?.startsWith("vscode-jest-tests.")
) ?? []),
...configs,
];
await writeFile(launchJson, JSON.stringify(launchConfig, null, 2) + "\n");

return 'Updated VSCode testing settings in ".vscode/settings.json" and ".vscode/launch.json".';
}

async function getWorkspaceFolders() {
const { workspaces } = packageJson;
/** @type {string[]} */
const folders = (
await Promise.all(
workspaces.map(async (pattern) => {
const result = [];
if (pattern.endsWith("/*")) {
const folder = pattern.slice(0, -2);
const dir = path.join(rootDir, folder);
const dirs = await readdir(dir, {
withFileTypes: true,
});
for (const d of dirs) {
const pkgJsonPath = path.join(dir, d.name, "package.json");
if (d.isDirectory() && existsSync(pkgJsonPath)) {
const pkg = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
if (pkg.scripts?.test) {
result.push(path.join(folder, d.name));
}
}
}
} else {
const dir = path.join(rootDir, pattern);
const pkgJsonPath = path.join(dir, "package.json");
if (existsSync(pkgJsonPath)) {
const pkg = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
if (pkg.scripts?.test) {
result.push(pattern);
}
}
}
return result;
})
)
).flat();
return folders;
}
Loading
Loading