diff --git a/.changeset/cli-serve-anchor-optional-import-at-app.md b/.changeset/cli-serve-anchor-optional-import-at-app.md new file mode 100644 index 0000000000..39a133bf79 --- /dev/null +++ b/.changeset/cli-serve-anchor-optional-import-at-app.md @@ -0,0 +1,44 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os serve` resolves app-declared optional service packages from the app, not the CWD (#11185) + +`serve` takes its config as an **argument**, so `objectstack serve /srv/app/objectstack.config.ts` +is a supported invocation and the app being served need not be the directory the operator +stood in. Every host-anchored optional load nevertheless used `process.cwd()` as its +resolution base, so with that invocation the CLI read the wrong `package.json`: a package the +app really does declare, and really does carry in its own `node_modules`, came back +`undeclared`, fell through to the framework-side fallback, and boot died — + +``` +Cannot find package '@objectstack/service-cluster': the host app does not declare it. + host app: /tmp/os-neutral-cwd-jXHXdF ← the CWD, not the app + (fallback resolution also failed: Cannot find package '@objectstack/service-cluster' + imported from …/packages/types/dist/node.mjs) +``` + +Measured on the released EE 4.1.0 image as `OS_CLUSTER_DRIVER=redis` ⇒ migrate exits 1 ⇒ the +whole stack cannot start. This is the same class as cloud#1013 and #10645 with the base wrong +for a different reason: those fixed the **importer** at these load sites (bare `import()` → +`importFromHost`); this fixes the **base** that importer is handed. + +`serve` now resolves the config path and the app root in one call (`anchorServedApp`), so the +anchor cannot be written too late or left out by a future author — the absolute config path +every later line needs is produced by the same call that sets it. Every host-anchored load in +the file defaults to that root, which is what generalises the repair to the next app-declared +optional service rather than fixing this one instance. The alternative route — declaring +`@objectstack/service-cluster*` in `packages/cli`'s own manifest — was rejected: it would make +the open-core CLI take a published dependency on packages it never imports, still leave every +third-party or future optional service broken, and change nothing for an app whose config is +addressed by path. + +The adopted root is the config's directory **only when that directory holds a `package.json`**, +and the CWD otherwise. `readHostDeclaration` reads a manifest — reachability is deliberately +not the contract (#4719) — so a directory with no manifest declares nothing and anchoring +there could only turn a working boot into an `undeclared` refusal. No layout that resolves +today resolves differently after this. + +The #4719 declaration gate is untouched: a package present in the app's `node_modules` but +absent from its `package.json` is still refused. The refusal's remedy (`host app: …`) now +names the app being served instead of an unrelated directory the operator happened to be in. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 45bf865575..50c5ee3cf7 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -217,6 +217,112 @@ type CapabilitySpec = { const hostImporters = new Map(); +/** + * The root of the app this process is serving — resolved once by + * {@link anchorServedApp}, and the default resolution base for every + * host-anchored load in this file. + * + * `undefined` until `run()` has resolved the config path, and for any caller + * that reaches {@link importFromHost} / {@link Serve.importConfigPlugin} + * outside a boot (a unit test, an out-of-tree embedder). Both fall back to + * `process.cwd()` through {@link servedAppRootOrCwd}, which is the base this + * file used unconditionally before — so a load that somehow runs before the + * anchor is set behaves exactly as it did, never worse. + */ +let servedAppRoot: string | undefined; + +/** + * The served app's root, or the CWD when no boot has anchored one. + * + * Read as a FUNCTION at each use, never captured into a module-scope `const`: + * the value is not knowable at module-evaluation time (it comes from the + * command's own argument), and a captured copy would silently freeze the + * pre-boot answer into every load site. + */ +function servedAppRootOrCwd(): string { + return servedAppRoot ?? process.cwd(); +} + +/** + * Resolve the served app's config path **and anchor host resolution at it**. + * + * ── Why the app root is the CONFIG's directory, not the CWD (#11185) ──────── + * + * `serve` takes the config as an ARGUMENT, so `objectstack serve + * /srv/app/objectstack.config.ts` is a supported invocation and the app being + * served need not be the directory the operator happened to stand in. Every + * host-anchored load resolved from `process.cwd()` regardless, so with that + * invocation the CLI read the WRONG `package.json`: an app-declared optional + * service — `@objectstack/service-cluster` and its driver, the enterprise + * organizations runtime, anything a customer installs — was classified + * `undeclared`, fell through to the framework-side fallback, and boot died with + * + * Cannot find package '@objectstack/service-cluster': the host app does not + * declare it. + * host app: + * + * while the app's own `node_modules` carried the package the whole time. That + * is #10645's outage with the base wrong for a different reason: #10645 fixed + * the IMPORTER (bare `import()` → {@link importFromHost}), this fixes the BASE + * that importer is handed. + * + * This function is also the only place `run()` computes the config path, on + * purpose. The anchor cannot be "forgotten" or written too late by a future + * author, because the value every later line needs — the absolute config path — + * is produced by the same call that sets it. + * + * ── Why the fallback is conditional, and the one row that MOVES ─────────── + * + * A resolution base is only useful if a `package.json` is there to be read + * (`readHostDeclaration` reads a MANIFEST — reachability is deliberately not + * the contract, #4719). So the config's directory is adopted only when it + * actually holds one, and otherwise the CWD is kept: + * + * • config beside the app's manifest, CWD = that app → unchanged (same dir) + * • config beside the app's manifest, CWD = elsewhere → FIXED: the app wins + * • config in a manifest-less subdirectory of the app → unchanged (CWD) + * • no config at all (artifact boot, `/dist/…`) → unchanged (CWD) + * + * Those four rows are unchanged. A FIFTH one is NOT, and this comment will not + * claim otherwise — an absolute here ("no layout that resolves today resolves + * differently") reads to the next author as a licence to skip the check: + * + * • config beside the app's manifest, CWD = elsewhere, the CWD's manifest + * DECLARES the package and the served app's does NOT → the load moves off + * the declared leg onto the fallback leg: it still succeeds wherever that + * fallback can reach the package, and refuses where it cannot. + * + * MEASURED. Before: hostRoot was the CWD, so `declared` was true and + * `hostRequire.resolve` found the package under the CWD's `node_modules`. + * After: hostRoot is the served app, `declared` is false, and the load goes to + * `createHostImporter`'s fallback — which is a bare `import()` physically + * inside `@objectstack/types`, because `importFromHost` passes no + * `fallbackImport` (#11157's residue). Node ESM walks `node_modules` UPWARD + * from there, so the common shape survives: in a hoisted monorepo whose ROOT + * manifest declares the package while the served `apps/foo/package.json` does + * not, that walk reaches the same hoisted store and boot is what it always + * was — only the leg changed. It genuinely refuses only where the walk cannot + * reach the package: a global / `npx` CLI serving an app elsewhere, with the + * optional service installed only beside the operator. Remedy for anyone who + * lands there — declare the package in the SERVED app's own `package.json`, + * which is what #4719 asks for regardless. + * + * So this narrows one row toward the declaration #4719 already requires; it + * widens nothing. The app root is the directory that DECLARES — a directory + * with no manifest declares nothing, which is why anchoring at one is not + * attempted: it could only turn a working boot into an `undeclared` refusal. + */ +function anchorServedApp(configArg: string): { configPath: string; configExists: boolean } { + const configPath = path.resolve(process.cwd(), configArg); + const configExists = fs.existsSync(configPath); + const configDir = path.dirname(configPath); + servedAppRoot = + configExists && fs.existsSync(path.join(configDir, 'package.json')) + ? configDir + : process.cwd(); + return { configPath, configExists }; +} + /** * Host-anchored dynamic import: load a package **as the app being served * declares it**, falling back to the CLI's own resolution when the app does not @@ -269,10 +375,13 @@ const hostImporters = new Map(); * this file for every app-declarable optional load and fails on a bare one. * * @param hostRoot Directory holding the served app's `package.json`. Defaults to - * the process CWD — the same root `serve` reads `objectstack.config.ts` from, and - * the value its boot path computes as `hostRoot`. + * {@link servedAppRootOrCwd} — the directory `serve` read the app's + * `objectstack.config.ts` from, which is the app's own root and NOT necessarily + * the process CWD (#11185). The default is what makes this helper correct from + * every line of the file without an author having to know a root exists: the + * same reason #10769 made it a hoisted declaration rather than a binding. */ -function importFromHost(specifier: string, hostRoot: string = process.cwd()): Promise { +function importFromHost(specifier: string, hostRoot: string = servedAppRootOrCwd()): Promise { // Memoised per root so one boot shares a single host `require`, exactly as the // one mid-function `const` did before it was hoisted out here. let importer = hostImporters.get(hostRoot); @@ -537,11 +646,12 @@ export default class Serve extends Command { * and this refusal is where that request would come from. * * @param pluginSpecifier The string as the app wrote it in `plugins: [...]`. - * @param hostRoot Root of the served app; defaults to the process CWD, the - * same value `serve`'s boot path computes. + * @param hostRoot Root of the served app; defaults to + * {@link servedAppRootOrCwd}, the same value `serve`'s boot path computes and + * passes explicitly at its own call site (#11185). */ static async importConfigPlugin(pluginSpecifier: string, hostRoot?: string): Promise { - const root = hostRoot ?? process.cwd(); + const root = hostRoot ?? servedAppRootOrCwd(); // Refused BEFORE the try, and deliberately not wrapped in the // `Failed to import plugin '': …` text below: nothing was imported, // and calling a refusal an import failure sends the author looking for a @@ -1206,7 +1316,10 @@ export default class Serve extends Command { const isDev = flags.dev || process.env.NODE_ENV === 'development'; - const absolutePath = path.resolve(process.cwd(), args.config!); + // Resolves the config path AND anchors every host-anchored load in this + // file at the app that owns it (#11185) — one call, so the anchor cannot be + // written too late or left out. See `anchorServedApp`. + const { configPath: absolutePath, configExists } = anchorServedApp(args.config!); const relativeConfig = path.relative(process.cwd(), absolutePath); // ── Artifact-first fallback ────────────────────────────────────── @@ -1218,7 +1331,7 @@ export default class Serve extends Command { // `apps/objectos/objectstack.config.ts`, lifted into the framework // so any project can `objectstack start` against just a // `dist/objectstack.json`. - const configMissing = !fs.existsSync(absolutePath); + const configMissing = !configExists; let useArtifactFallback = false; let useEmptyBoot = false; @@ -1684,14 +1797,20 @@ export default class Serve extends Command { // The root of the app being served: where its `package.json` and // `objectstack.config.ts` live. `importFromHost` (module scope, top of this // file) anchors every app-declarable optional load here, and defaults to - // this same `process.cwd()`, so a load written ANYWHERE in this method — - // above this line included — resolves from the app rather than the CLI. - // That reachability is the point: see the helper's own note for the two - // shipped instances (cloud#1013, #10645) that a mid-function binding cost. + // this same value, so a load written ANYWHERE in this method — above this + // line included — resolves from the app rather than the CLI. That + // reachability is the point: see the helper's own note for the two shipped + // instances (cloud#1013, #10645) that a mid-function binding cost. + // + // NOT `process.cwd()`: the config is an ARGUMENT, so the app being served + // is the directory that config was read from, which need not be the + // directory the operator stood in (#11185). `anchorServedApp` resolved it + // at the top of this method; this reads the same answer rather than + // recomputing a second one, so the file has ONE notion of the app root. // // #4719: what the host root DECLARES is the contract; being merely // reachable through a hoisted workspace store is not, and is refused. - const hostRoot = process.cwd(); + const hostRoot = servedAppRootOrCwd(); // Cluster wiring: env-driven driver selection (mirrors OS_DATABASE_URL). // The remote driver self-registers on import; import it dynamically so it diff --git a/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts b/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts new file mode 100644 index 0000000000..2c8d67cccd --- /dev/null +++ b/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11185 — `os serve` resolves an app-declared OPTIONAL service package from the + * app it is serving, even when that app is not the process CWD. + * + * ── The defect ─────────────────────────────────────────────────────────── + * + * The config is an ARGUMENT (`objectstack serve /srv/app/objectstack.config.ts`), + * so the app being served is whatever directory that config was read from. Every + * host-anchored load nevertheless took `process.cwd()` as its resolution base, so + * with that invocation the CLI consulted the WRONG `package.json`: a package the + * app really does declare and really does carry in its own `node_modules` came + * back `undeclared`, fell through to the framework-side fallback, and boot died — + * + * Cannot find package '@objectstack/service-cluster': the host app does not + * declare it. + * host app: + * (fallback resolution also failed: Cannot find package + * '@objectstack/service-cluster' imported from …/packages/types/dist/node.mjs) + * + * Measured on the released EE 4.1.0 image as `OS_CLUSTER_DRIVER=redis` ⇒ migrate + * exits 1 ⇒ the whole stack cannot start. #10645/#10769 fixed the IMPORTER at + * these load sites (bare `import()` → `importFromHost`); this card fixes the BASE + * that importer is handed. + * + * ── Why this file spawns the real CLI ──────────────────────────────────── + * + * The base is computed by `run()` from its own argument. Nothing below `run()` + * can observe it, so an in-process test of `createHostImporter` — which is what + * `src/commands/serve-cluster-host-resolution.test.ts` pins — is green either + * way: it hands the importer a root the test itself chose, which is precisely + * the value that was wrong. Only a real `serve` process, given a real app + * directory and a real CWD that is NOT that directory, exercises it. + * + * The spawn is written out here rather than taken from `test/helpers/ + * serve-process.ts` on purpose: that helper always runs the child WITH `cwd` set + * to the app, which is the one shape this file must not use. + * + * ── The anti-vacuity floor ─────────────────────────────────────────────── + * + * The fixture packages exist ONLY in the app's `node_modules`. Nothing in this + * workspace can supply `@objectstack/service-cluster` to `packages/cli` — the + * CLI does not declare it (that is what makes it app-declarable at all), and the + * fixtures are written to a temp directory with a fake `index.js` no build + * produces. A pass therefore cannot come from the CLI's own resolution by + * accident: the marker line these fixtures print is reachable only across the + * boundary the card is about. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** `bin/run-dev.js` — this package's own entrypoint, run from TS source. */ +const CLI = resolve(HERE, '../bin/run-dev.js'); +/** The workspace `tsx` binary (an installed dependency, not a repo source input). */ +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +const CONFIG = ` +export default { + manifest: { + id: 'com.example.anchoredimport', + namespace: 'anchoredimport', + version: '1.0.0', + type: 'app', + name: 'App-Anchored Optional Import Fixture', + }, +}; +`; + +/** The gate package `serve` loads first when OS_CLUSTER_DRIVER is set. */ +const CLUSTER = '@objectstack/service-cluster'; +/** The driver it loads next, named from the env var. */ +const DRIVER = '@objectstack/service-cluster-redis'; + +const CLUSTER_MARK = '[fixture] app-local @objectstack/service-cluster loaded'; +const DRIVER_MARK = '[fixture] app-local @objectstack/service-cluster-redis loaded'; + +/** + * Stand-in for the distribution cluster gate. `checkMultiNodeAllowed` is the one + * export `serve` destructures; returning `allowed` keeps the boot walking on to + * the driver load, so one run exercises both host-anchored sites. + */ +const FAKE_CLUSTER = ` +console.error(${JSON.stringify(CLUSTER_MARK)}); +export function checkMultiNodeAllowed() { return { allowed: true }; } +`; + +const FAKE_DRIVER = ` +console.error(${JSON.stringify(DRIVER_MARK)}); +`; + +function writeAppLocalPackage(nodeModules: string, name: string, body: string): void { + const dir = join(nodeModules, ...name.split('/')); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name, version: '0.0.0-fixture', type: 'module', main: 'index.js' }), + 'utf8', + ); + writeFileSync(join(dir, 'index.js'), body, 'utf8'); +} + +/** + * An app whose optional service packages live in its OWN `node_modules`. + * + * `declare` splits the two halves of the contract: declared is the repair, + * undeclared must still be refused (#4719 — reachability is not a declaration, + * and moving the resolution base must not quietly widen what `serve` accepts). + */ +function writeApp(prefix: string, declare: boolean): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8'); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: 'anchored-import-fixture', + private: true, + type: 'module', + ...(declare ? { dependencies: { [CLUSTER]: '*', [DRIVER]: '*' } } : {}), + }, + null, + 2, + ), + 'utf8', + ); + const nodeModules = join(dir, 'node_modules'); + writeAppLocalPackage(nodeModules, CLUSTER, FAKE_CLUSTER); + writeAppLocalPackage(nodeModules, DRIVER, FAKE_DRIVER); + return dir; +} + +interface Run { stdout: string; stderr: string; both: string } + +/** + * Boot `serve` with an explicit `cwd` and an explicit config argument, collect + * output until `waitFor` matches or the child exits, then stop it. + * + * An early exit resolves rather than rejects: a boot that DIES still has to have + * said why, and the refusal case below reads exactly that. + */ +function runServeFrom( + cwd: string, + configArg: string, + waitFor: RegExp, + timeoutMs = 240_000, +): Promise { + return new Promise((resolveRun) => { + const port = String(40000 + Math.floor(Math.random() * 20000)); + const child = spawn(TSX, [CLI, 'serve', configArg, '--port', port], { + cwd, + env: { + ...process.env, + NO_COLOR: '1', + OS_DATABASE_URL: ':memory:', + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + // The trigger: without a non-memory driver the cluster block is skipped + // entirely and this file would measure nothing. + OS_CLUSTER_DRIVER: 'redis', + }, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child.kill('SIGTERM'); } catch { /* already gone */ } + resolveRun({ stdout, stderr, both: stdout + stderr }); + }; + const timer = setTimeout(finish, timeoutMs); + const onData = (chunk: unknown, stream: 'out' | 'err') => { + if (stream === 'out') stdout += String(chunk); else stderr += String(chunk); + if (waitFor.test(stdout + stderr)) finish(); + }; + child.stdout.on('data', (d) => onData(d, 'out')); + child.stderr.on('data', (d) => onData(d, 'err')); + child.on('exit', finish); + child.on('error', finish); + }); +} + +/** Matches either outcome, so a run never waits out its timeout. */ +const SETTLED = new RegExp( + `${DRIVER_MARK.replace(/[[\]]/g, '\\$&')}|does not declare it|Press Ctrl\\+C to stop`, +); + +let declaredApp: string; +let undeclaredApp: string; +/** A CWD that is not any app: no config, no manifest, nothing to resolve from. */ +let neutralCwd: string; + +beforeAll(() => { + declaredApp = writeApp('os-anchored-declared-', true); + undeclaredApp = writeApp('os-anchored-undeclared-', false); + neutralCwd = mkdtempSync(join(tmpdir(), 'os-anchored-neutral-cwd-')); +}); + +afterAll(() => { + for (const dir of [declaredApp, undeclaredApp, neutralCwd]) { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('os serve → optional service resolution is anchored at the app (#11185)', () => { + it( + 'loads an app-local-only optional package when the CWD is NOT the app', + async () => { + const run = await runServeFrom(neutralCwd, join(declaredApp, 'objectstack.config.ts'), SETTLED); + const seen = `\n--- stdout ---\n${run.stdout.slice(-4000)}\n--- stderr ---\n${run.stderr.slice(-4000)}`; + + // The load-bearing assertion. Before the fix the base was `process.cwd()` + // — the neutral directory — so the app's declaration was never read and + // the boot died before either marker was printed. + expect(run.both, `the cluster gate was not loaded from the app${seen}`).toContain(CLUSTER_MARK); + expect(run.both, `the cluster driver was not loaded from the app${seen}`).toContain(DRIVER_MARK); + expect(run.both, `serve refused a package the app DOES declare${seen}`).not.toContain( + 'does not declare it', + ); + }, + 300_000, + ); + + it( + 'still loads it when the CWD IS the app (the shape #10645 fixed stays fixed)', + async () => { + // Passes on both trees: it is the control that proves the fixture and the + // boot path are real, so a failure in the test above is the BASE and not a + // broken fixture. + const run = await runServeFrom(declaredApp, 'objectstack.config.ts', SETTLED); + const seen = `\n--- stdout ---\n${run.stdout.slice(-4000)}\n--- stderr ---\n${run.stderr.slice(-4000)}`; + expect(run.both, `the cluster gate was not loaded from the app${seen}`).toContain(CLUSTER_MARK); + expect(run.both, `the cluster driver was not loaded from the app${seen}`).toContain(DRIVER_MARK); + }, + 300_000, + ); + + it( + 'still refuses a package the app does not declare, and names the APP', + async () => { + // The other half: moving the base must not turn the #4719 declaration gate + // into "whatever is reachable". The package IS in this app's node_modules + // and is still refused — and the remedy now points at the app, which is + // where the declaration has to go. Before the fix it named the CWD, an + // unrelated directory the operator was merely standing in. + const run = await runServeFrom( + neutralCwd, + join(undeclaredApp, 'objectstack.config.ts'), + SETTLED, + ); + const seen = `\n--- stdout ---\n${run.stdout.slice(-4000)}\n--- stderr ---\n${run.stderr.slice(-4000)}`; + + expect(run.both, `the declaration gate did not fire${seen}`).toContain('does not declare it'); + expect(run.both, `the remedy does not name the app being served${seen}`).toContain( + `host app: ${undeclaredApp}`, + ); + expect(run.both, `the remedy still names the CWD${seen}`).not.toContain( + `host app: ${neutralCwd}`, + ); + expect(run.both, `reachability substituted for declaration${seen}`).not.toContain(CLUSTER_MARK); + }, + 300_000, + ); +}); + +describe('os serve → the anchor is wired where it cannot be forgotten', () => { + const SERVE_SOURCE = readFileSync(resolve(HERE, '../src/commands/serve.ts'), 'utf8'); + + it('resolves the config path and the app root in ONE call', () => { + // If `run()` ever computes the config path itself again, the anchor becomes + // a separate statement someone can write too late — or not at all — and the + // behavioural tests above would be the only thing standing between that and + // a silent return to CWD-based resolution. + expect(SERVE_SOURCE).toContain( + 'const { configPath: absolutePath, configExists } = anchorServedApp(args.config!);', + ); + expect(SERVE_SOURCE).not.toMatch( + /const absolutePath = path\.resolve\(process\.cwd\(\), args\.config!\)/, + ); + }); + + it('defaults every host-anchored load to the served app, not the CWD', () => { + expect(SERVE_SOURCE).toContain( + 'function importFromHost(specifier: string, hostRoot: string = servedAppRootOrCwd())', + ); + expect(SERVE_SOURCE).toContain('const hostRoot = servedAppRootOrCwd();'); + expect(SERVE_SOURCE).toContain('const root = hostRoot ?? servedAppRootOrCwd();'); + }); + + it('reads the app root through a function, never a module-scope copy', () => { + // A `const` captured at module-evaluation time would freeze the pre-boot + // answer (`process.cwd()`) into every call site, which is the defect wearing + // a different hat. + expect(SERVE_SOURCE).toMatch(/^function servedAppRootOrCwd\(\): string \{$/m); + expect(SERVE_SOURCE).not.toMatch(/\b(?:const|let|var)\s+servedAppRootOrCwd\b/); + }); +});