@pyric/cli 0.1.0-alpha.30 → 0.1.0-alpha.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/assurance/.generated/conformance-verdicts.d.ts +4 -0
  2. package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
  3. package/dist/assurance/.generated/conformance-verdicts.js +4 -0
  4. package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
  5. package/dist/cli/serve.d.ts +2 -0
  6. package/dist/cli/serve.d.ts.map +1 -1
  7. package/dist/cli/serve.js +13 -2
  8. package/dist/cli/serve.js.map +1 -1
  9. package/dist/conformance/.generated/can-i-use-browser.js +2 -2
  10. package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
  11. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  12. package/dist/conformance/.generated/can-i-use.js +1 -1
  13. package/dist/conformance/.generated/can-i-use.js.map +1 -1
  14. package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
  15. package/dist/conformance/.generated/conformance-docs.js +1 -1
  16. package/dist/conformance/.generated/conformance-docs.js.map +1 -1
  17. package/dist/register/app-bridge-admin-app-check.d.ts +3 -0
  18. package/dist/register/app-bridge-admin-app-check.d.ts.map +1 -0
  19. package/dist/register/app-bridge-admin-app-check.js +43 -0
  20. package/dist/register/app-bridge-admin-app-check.js.map +1 -0
  21. package/dist/register/app-bridge-app-check.d.ts +4 -0
  22. package/dist/register/app-bridge-app-check.d.ts.map +1 -1
  23. package/dist/register/app-bridge-app-check.js +4 -0
  24. package/dist/register/app-bridge-app-check.js.map +1 -1
  25. package/dist/register/app-bridge-shadow.d.ts +4 -2
  26. package/dist/register/app-bridge-shadow.d.ts.map +1 -1
  27. package/dist/register/app-bridge-shadow.js +29 -5
  28. package/dist/register/app-bridge-shadow.js.map +1 -1
  29. package/dist/register/index.d.ts.map +1 -1
  30. package/dist/register/index.js +21 -0
  31. package/dist/register/index.js.map +1 -1
  32. package/dist/serve/bundler.d.ts +40 -0
  33. package/dist/serve/bundler.d.ts.map +1 -1
  34. package/dist/serve/bundler.js +133 -2
  35. package/dist/serve/bundler.js.map +1 -1
  36. package/dist/serve/entries/app-ai-passthrough.d.ts +4 -4
  37. package/dist/serve/entries/app-ai-passthrough.d.ts.map +1 -1
  38. package/dist/serve/entries/app-ai-passthrough.js +8 -52
  39. package/dist/serve/entries/app-ai-passthrough.js.map +1 -1
  40. package/dist/serve/firebase-module-swap.d.ts +22 -0
  41. package/dist/serve/firebase-module-swap.d.ts.map +1 -1
  42. package/dist/serve/firebase-module-swap.js +51 -3
  43. package/dist/serve/firebase-module-swap.js.map +1 -1
  44. package/dist/serve/hosted/method-protocol.d.ts +4 -4
  45. package/dist/serve/html-injection.d.ts +2 -0
  46. package/dist/serve/html-injection.d.ts.map +1 -1
  47. package/dist/serve/html-injection.js +4 -2
  48. package/dist/serve/html-injection.js.map +1 -1
  49. package/dist/serve/namespace.d.ts.map +1 -1
  50. package/dist/serve/namespace.js +5 -1
  51. package/dist/serve/namespace.js.map +1 -1
  52. package/dist/serve/site-ui/docs/ai-compat/index.html +9 -9
  53. package/dist/serve/site-ui/docs/ai-compat.md +9 -9
  54. package/dist/serve/site-ui/docs/app-check-compat/index.html +42 -18
  55. package/dist/serve/site-ui/docs/app-check-compat.md +42 -18
  56. package/dist/serve/site-ui/docs/auth-compat/index.html +4 -4
  57. package/dist/serve/site-ui/docs/auth-compat.md +4 -4
  58. package/dist/serve/site-ui/docs/build/ai-logic/index.html +2 -0
  59. package/dist/serve/site-ui/docs/build/ai-logic.md +4 -0
  60. package/dist/serve/site-ui/docs/database-compat/index.html +4 -4
  61. package/dist/serve/site-ui/docs/database-compat.md +4 -4
  62. package/dist/serve/site-ui/docs/firestore-compat/index.html +4 -4
  63. package/dist/serve/site-ui/docs/firestore-compat.md +4 -4
  64. package/dist/serve/site-ui/docs/messaging-compat/index.html +4 -4
  65. package/dist/serve/site-ui/docs/messaging-compat.md +4 -4
  66. package/dist/serve/site-ui/docs/pyric-admin-app-reference-api/index.html +4 -0
  67. package/dist/serve/site-ui/docs/pyric-admin-app-reference-api.md +5 -0
  68. package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api/index.html +2 -0
  69. package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api.md +3 -0
  70. package/dist/serve/site-ui/docs/storage-compat/index.html +5 -5
  71. package/dist/serve/site-ui/docs/storage-compat.md +5 -5
  72. package/dist/serve/site-ui/examples/chess/index.html +1 -1
  73. package/package.json +4 -4
  74. package/src/assurance/.generated/conformance-verdicts.ts +4 -0
  75. package/src/cli/serve.ts +14 -2
  76. package/src/conformance/.generated/can-i-use-browser.ts +2 -2
  77. package/src/conformance/.generated/can-i-use.ts +1 -1
  78. package/src/conformance/.generated/conformance-docs.ts +1 -1
  79. package/src/register/app-bridge-admin-app-check.ts +46 -0
  80. package/src/register/app-bridge-app-check.ts +4 -0
  81. package/src/register/app-bridge-shadow.ts +35 -5
  82. package/src/register/index.ts +23 -0
  83. package/src/serve/bundler.ts +163 -2
  84. package/src/serve/entries/app-ai-passthrough.ts +8 -58
  85. package/src/serve/firebase-module-swap.ts +52 -3
  86. package/src/serve/html-injection.ts +4 -2
  87. package/src/serve/namespace.ts +5 -1
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Production AI mode's App Check verifier for a registered Node process
3
+ * (#1552): firebase-admin's own `verifyToken`, for the tokens the page gets
4
+ * from Firebase.
5
+ *
6
+ * The register installs it behind `pyric.admin.appCheck.firebaseVerifier` in
7
+ * production AI mode only, and loads this module on the first token whose
8
+ * `kid` is not the sandbox's, so a server that never sees one never loads
9
+ * firebase-admin. `firebase-admin/app` and `firebase-admin/app-check` reach
10
+ * the real package from here (`isBypassedBridgeImport`).
11
+ *
12
+ * Each project gets one firebase-admin app whose credential refuses to mint an
13
+ * access token, so the app cannot fall back to Application Default
14
+ * Credentials, the metadata server, or Google's OAuth endpoints. Verifying a
15
+ * token needs none of them: firebase-admin fetches Firebase's public keys from
16
+ * `https://firebaseappcheck.googleapis.com/v1/jwks` without credentials and
17
+ * caches them for six hours. `consume` is never passed here; the mirror
18
+ * records it in the process.
19
+ *
20
+ * The export is typed loosely on purpose: the published declarations of
21
+ * @pyric/cli carry no Firebase SDK edge. pyric-admin types the result.
22
+ */
23
+ import { initializeApp, type App, type Credential } from 'firebase-admin/app';
24
+ import { getAppCheck } from 'firebase-admin/app-check';
25
+
26
+ const NO_CREDENTIALS = "Pyric's App Check verifier holds no credentials.";
27
+
28
+ /** A credential that never mints an access token. */
29
+ const noCredentials: Credential = {
30
+ getAccessToken: () => Promise.reject(new Error(NO_CREDENTIALS)),
31
+ };
32
+
33
+ const appsByProject = new Map<string, App>();
34
+
35
+ function verifierApp(projectId: string): App {
36
+ const existing = appsByProject.get(projectId);
37
+ if (existing !== undefined) return existing;
38
+ const app = initializeApp({ projectId, credential: noCredentials }, `[pyric] app-check verifier ${projectId}`);
39
+ appsByProject.set(projectId, app);
40
+ return app;
41
+ }
42
+
43
+ /** firebase-admin's `verifyToken(token)` for an app scoped to `projectId`, with no options. */
44
+ export function verifyFirebaseAppCheckToken(token: string, projectId: string): Promise<unknown> {
45
+ return getAppCheck(verifierApp(projectId)).verifyToken(token);
46
+ }
@@ -20,6 +20,10 @@ import { shadowAppOf } from './app-bridge-shadow.js';
20
20
 
21
21
  type RealInitialize = typeof realAppCheck.initializeAppCheck;
22
22
 
23
+ /**
24
+ * `shadowAppOf` throws when the shadow app could not be created, so that
25
+ * cause surfaces here before the SDK would fail on a missing container.
26
+ */
23
27
  export function initializeAppCheck(app: { name: string } | undefined, options: unknown): unknown {
24
28
  const shadow = app === undefined ? undefined : shadowAppOf(app);
25
29
  return realAppCheck.initializeAppCheck(shadow as Parameters<RealInitialize>[0], options as Parameters<RealInitialize>[1]);
@@ -2,7 +2,8 @@
2
2
  * Production AI pass-through for canonical `firebase/app` in a registered Node
3
3
  * process: each Pyric app gets a shadow real Firebase app whose container the
4
4
  * real `firebase/ai` reads. Shared by the in-process adapter
5
- * (`./app-bridge.ts`) and the Node host client (`./client/app-bridge.ts`).
5
+ * (`./app-bridge.ts`), the Node host client (`./client/app-bridge.ts`), and
6
+ * the served `firebase/app` entry (`serve/entries/app-ai-passthrough.ts`).
6
7
  */
7
8
  import * as realApp from 'firebase/app';
8
9
  import type { FirebaseApp, FirebaseAppSettings, FirebaseOptions } from 'pyric/app';
@@ -14,6 +15,27 @@ export interface PyricAppRegistry {
14
15
  deleteApp(app: FirebaseApp): Promise<void>;
15
16
  }
16
17
 
18
+ /**
19
+ * Why the shadow app for a name could not be created, kept until a later
20
+ * attempt succeeds or the app is deleted. It sits behind `Symbol.for` so every
21
+ * bundle or module record of this file on a page or in a process shares it.
22
+ */
23
+ const SHADOW_FAILURES = Symbol.for('pyric.ai.shadowAppFailures');
24
+ type GlobalShadowFailures = { [SHADOW_FAILURES]?: Map<string, unknown> };
25
+ const shadowFailures = ((globalThis as GlobalShadowFailures)[SHADOW_FAILURES] ??= new Map<string, unknown>());
26
+
27
+ function causeText(cause: unknown): string {
28
+ const text = cause instanceof Error ? cause.message : String(cause);
29
+ return text.replace(/\.$/, '');
30
+ }
31
+
32
+ function shadowFailedError(name: string, cause: unknown): Error {
33
+ return new Error(
34
+ `Pyric could not create the Firebase app that firebase/app-check and firebase/ai attach to in production AI mode, for app "${name}": ${causeText(cause)}.`,
35
+ { cause },
36
+ );
37
+ }
38
+
17
39
  function decorateApp(
18
40
  app: FirebaseApp,
19
41
  options?: FirebaseOptions,
@@ -34,24 +56,31 @@ function decorateApp(
34
56
  shadow = realApp.initializeApp(initOptions, objectSettings) as { container?: unknown };
35
57
  }
36
58
  }
59
+ shadowFailures.delete(app.name);
37
60
  const hasShadowContainer = shadow.container !== undefined && shadow.container !== null;
38
61
  if (hasShadowContainer) {
39
62
  Object.defineProperty(app, 'container', { value: shadow.container, configurable: true });
40
63
  }
41
- } catch {
42
- // Observational/best-effort: do not break Pyric initialization if shadow initialization fails.
64
+ } catch (error) {
65
+ // Pyric's initializeApp still succeeds; shadowAppOf reports the cause
66
+ // when firebase/app-check or firebase/ai needs the shadow.
67
+ shadowFailures.set(app.name, error);
43
68
  }
44
69
  return app;
45
70
  }
46
71
 
47
72
  /**
48
- * The shadow Firebase app for a Pyric app, by name. A Firebase app passed in
49
- * directly, or a Pyric app with no shadow yet, is returned unchanged.
73
+ * The shadow Firebase app for a Pyric app, by name. When creating it failed,
74
+ * throws an error that names the app and keeps the failure as `cause`. A
75
+ * Firebase app passed in directly, or a Pyric app with no attempt yet, is
76
+ * returned unchanged.
50
77
  */
51
78
  export function shadowAppOf(app: { name: string }): object {
52
79
  try {
53
80
  return realApp.getApp(app.name);
54
81
  } catch {
82
+ const hasFailure = shadowFailures.has(app.name);
83
+ if (hasFailure) throw shadowFailedError(app.name, shadowFailures.get(app.name));
55
84
  return app;
56
85
  }
57
86
  }
@@ -69,6 +98,7 @@ export function shadowedAppRegistry(pyricApp: PyricAppRegistry): PyricAppRegistr
69
98
  return pyricApp.getApps().map((app) => decorateApp(app));
70
99
  },
71
100
  async deleteApp(app) {
101
+ shadowFailures.delete(app.name);
72
102
  try {
73
103
  const shadow = realApp.getApp(app.name);
74
104
  const hasShadow = shadow !== undefined && shadow !== null;
@@ -38,12 +38,16 @@ import { resolveEsmOnlySubpath } from './esm-exports.js';
38
38
  import { NET_GUARD_GLOBAL, installNetGuard, parseGuardMode, type GuardMode } from './net-guard.js';
39
39
  import { emitBeacon } from './beacon.js';
40
40
  import { remoteSandbox } from '../remote/index.js';
41
+ import { resolveAiMode } from '../serve/firebase-module-swap.js';
41
42
 
42
43
  /** The shared seam with pyric-admin's ambient init: a synchronous factory
43
44
  * returning the lazy branded handle. `Symbol.for` so both packages agree
44
45
  * without importing each other's types. */
45
46
  const SANDBOX_FACTORY = Symbol.for('pyric.remote.sandboxFactory');
46
47
 
48
+ /** The seam with pyric-admin's App Check mirror: firebase-admin's verifier for tokens from Firebase (#1552). */
49
+ const APP_CHECK_FIREBASE_VERIFIER = Symbol.for('pyric.admin.appCheck.firebaseVerifier');
50
+
47
51
  interface SyncResolveContext {
48
52
  parentURL?: string;
49
53
  conditions?: string[];
@@ -170,6 +174,8 @@ function activate(): void {
170
174
  (globalThis as Record<symbol, unknown>)[SANDBOX_FACTORY] = (opts?: { url?: string }) =>
171
175
  remoteSandbox({ configuredUrl: opts?.url, instanceId: process.env.PYRIC_SANDBOX_INSTANCE });
172
176
 
177
+ installAppCheckVerifier();
178
+
173
179
  process.stderr.write(
174
180
  `@pyric/cli/register: active. firebase-admin/firebase imports now resolve to the ` +
175
181
  `pyric sandbox (PYRIC_SANDBOX=${process.env.PYRIC_SANDBOX}).\n`,
@@ -187,6 +193,23 @@ function activate(): void {
187
193
  });
188
194
  }
189
195
 
196
+ /**
197
+ * Production AI mode only: the page's App Check tokens come from Firebase, so
198
+ * pyric-admin's mirror hands them to firebase-admin's own verifier (#1552).
199
+ * The bridge, and firebase-admin with it, loads on the first such token, so a
200
+ * server that never verifies one never loads firebase-admin. In sandbox AI
201
+ * mode nothing is installed, and the mirror refuses such a token with a
202
+ * message that says why.
203
+ */
204
+ function installAppCheckVerifier(): void {
205
+ const isProductionAiMode = resolveAiMode(undefined, process.env) === 'production';
206
+ if (!isProductionAiMode) return;
207
+ (globalThis as Record<symbol, unknown>)[APP_CHECK_FIREBASE_VERIFIER] = async (token: string, projectId: string) => {
208
+ const bridge = await import('./app-bridge-admin-app-check.js');
209
+ return bridge.verifyFirebaseAppCheckToken(token, projectId);
210
+ };
211
+ }
212
+
190
213
  /**
191
214
  * The net-guard mode actually in force, for the beacon to report.
192
215
  *
@@ -28,6 +28,8 @@ import { dirname, join, relative, sep } from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import * as esbuild from 'esbuild';
30
30
 
31
+ import { bridgeImportEntryKey, REAL_SDK_ENTRIES } from './firebase-module-swap.js';
32
+
31
33
  export { SDK_MODULES } from './firebase-module-swap.js';
32
34
 
33
35
  /**
@@ -160,6 +162,145 @@ function nodeShimPlugin(): esbuild.Plugin {
160
162
  };
161
163
  }
162
164
 
165
+ /**
166
+ * Send the shadow-app wrappers' own `firebase/app` and `firebase/app-check`
167
+ * imports to the Firebase SDK bundle `pyric sandbox` builds in production AI
168
+ * mode (`/__pyric/sdk/firebase-sdk-*.js`). Left bare, the import map would
169
+ * send them back to the wrappers themselves. The rule is
170
+ * `isBypassedBridgeImport`, which the Vite and register planes read too.
171
+ */
172
+ function bridgeImportPlugin(): esbuild.Plugin {
173
+ return {
174
+ name: 'pyric-serve-bridge-imports',
175
+ setup(build) {
176
+ build.onResolve({ filter: /^firebase\// }, (args) => {
177
+ const key = bridgeImportEntryKey(args.path, args.importer);
178
+ if (key === undefined) return undefined;
179
+ return { path: `/__pyric/sdk/${key}.js`, external: true };
180
+ });
181
+ },
182
+ };
183
+ }
184
+
185
+ // ─── the project's Firebase SDK (production AI mode) ─────────────────
186
+
187
+ /** The project's resolved `firebase` package. */
188
+ export interface ProjectFirebase {
189
+ /** The package directory, as resolved from the project. */
190
+ root: string;
191
+ version: string;
192
+ }
193
+
194
+ /**
195
+ * Find the `firebase` package the project's own code resolves, walking up
196
+ * `node_modules` directories from `projectDir` as Node does. Throws a message
197
+ * that tells the user to install it when there is none.
198
+ */
199
+ export function resolveProjectFirebase(projectDir: string): ProjectFirebase {
200
+ let dir = projectDir;
201
+ while (true) {
202
+ const manifest = join(dir, 'node_modules', 'firebase', 'package.json');
203
+ if (existsSync(manifest)) {
204
+ const pkg = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: string };
205
+ return { root: dirname(manifest), version: pkg.version ?? '0.0.0' };
206
+ }
207
+ const parent = dirname(dir);
208
+ if (parent === dir) break;
209
+ dir = parent;
210
+ }
211
+ throw new Error(
212
+ `pyric sandbox: production AI mode (${productionModeSetting(process.env)}) serves the Firebase SDK from this project, ` +
213
+ `but no firebase package resolves from ${projectDir}. Install it with \`npm install firebase\`, ` +
214
+ `or unset ${productionModeSetting(process.env).split('=')[0]} to answer firebase/ai from the Pyric sandbox.`,
215
+ );
216
+ }
217
+
218
+ /**
219
+ * The environment setting that turned production AI mode on, for messages.
220
+ * `PYRIC_AI_MODE=production` wins; otherwise the passthrough switch did it.
221
+ */
222
+ export function productionModeSetting(env: NodeJS.ProcessEnv): string {
223
+ const isModeSet = env.PYRIC_AI_MODE === 'production';
224
+ return isModeSet ? 'PYRIC_AI_MODE=production' : 'PYRIC_AI_PASSTHROUGH=1';
225
+ }
226
+
227
+ export interface ProjectFirebasePlan {
228
+ /** Pass to `bundleSdk`; absent when the real-SDK build is skipped. */
229
+ projectFirebase?: { projectDir: string };
230
+ /** One line to log when the build is skipped. */
231
+ warning?: string;
232
+ }
233
+
234
+ /**
235
+ * Decide whether production AI mode builds the Firebase SDK. Plain
236
+ * `pyric sandbox` serves the page itself, so a missing `firebase` throws.
237
+ * With a child command the child resolves `firebase` on its own, so a
238
+ * missing one only skips the build and warns.
239
+ */
240
+ export function planProjectFirebase(projectDir: string, hasChildCommand: boolean, env: NodeJS.ProcessEnv = process.env): ProjectFirebasePlan {
241
+ try {
242
+ resolveProjectFirebase(projectDir);
243
+ } catch (error) {
244
+ if (!hasChildCommand) throw error;
245
+ return {
246
+ warning:
247
+ ` ⚠ production AI mode (${productionModeSetting(env)}): no firebase package resolves from ${projectDir}, ` +
248
+ `so the Firebase SDK is not built. Pages the sandbox serves itself (import map) need firebase installed where pyric starts; ` +
249
+ `your command resolves firebase on its own.`,
250
+ };
251
+ }
252
+ return { projectFirebase: { projectDir } };
253
+ }
254
+
255
+
256
+ const REAL_SDK_NAMESPACE = 'pyric-firebase-sdk';
257
+
258
+ /**
259
+ * Build `firebase/app`, `firebase/ai` and `firebase/app-check` from the
260
+ * project's `firebase` install into `outDir`, in one pass with splitting so
261
+ * the three share one `@firebase/app` component registry, as Vite's
262
+ * dependency optimizer does.
263
+ */
264
+ async function bundleProjectFirebase(outDir: string, projectDir: string, firebase: ProjectFirebase, minify: boolean): Promise<void> {
265
+ const entryPoints: Record<string, string> = {};
266
+ for (const [specifier, key] of REAL_SDK_ENTRIES) entryPoints[key] = `${REAL_SDK_NAMESPACE}:${specifier}`;
267
+ const result = await esbuild.build({
268
+ entryPoints,
269
+ outdir: outDir,
270
+ bundle: true,
271
+ format: 'esm',
272
+ platform: 'browser',
273
+ splitting: true,
274
+ sourcemap: 'linked',
275
+ minify,
276
+ logLevel: 'silent',
277
+ chunkNames: 'firebase-sdk-[hash]',
278
+ banner: {
279
+ js: `/* The Firebase JS SDK, firebase@${firebase.version} from this project, built by pyric sandbox for production AI mode. */`,
280
+ },
281
+ plugins: [{
282
+ name: 'pyric-serve-project-firebase',
283
+ setup(build) {
284
+ build.onResolve({ filter: new RegExp(`^${REAL_SDK_NAMESPACE}:`) }, (args) => ({
285
+ path: args.path.slice(REAL_SDK_NAMESPACE.length + 1),
286
+ namespace: REAL_SDK_NAMESPACE,
287
+ }));
288
+ build.onLoad({ filter: /.*/, namespace: REAL_SDK_NAMESPACE }, (args) => ({
289
+ contents: `export * from ${JSON.stringify(args.path)};`,
290
+ resolveDir: projectDir,
291
+ loader: 'js',
292
+ }));
293
+ },
294
+ }],
295
+ });
296
+ const hasBuildErrors = result.errors.length > 0;
297
+ if (hasBuildErrors) {
298
+ throw new Error(
299
+ `pyric sandbox: building firebase@${firebase.version} for production AI mode failed:\n${result.errors.map((e) => e.text).join('\n')}`,
300
+ );
301
+ }
302
+ }
303
+
163
304
  // ─── cache ────────────────────────────────────────────────────────────
164
305
 
165
306
  export interface BundleOptions {
@@ -171,6 +312,12 @@ export interface BundleOptions {
171
312
  /** Override the cache root (tests). Default `~/.pyric/serve-cache`. */
172
313
  cacheRoot?: string;
173
314
  minify?: boolean;
315
+ /**
316
+ * Production AI mode: also build the Firebase SDK from this project's own
317
+ * `firebase` install (`/__pyric/sdk/firebase-sdk-*.js`). Absent in sandbox
318
+ * AI mode, which neither builds nor serves it.
319
+ */
320
+ projectFirebase?: { projectDir: string };
174
321
  }
175
322
 
176
323
  export interface BundleResult {
@@ -184,7 +331,7 @@ export interface BundleResult {
184
331
  }
185
332
 
186
333
  /** Bump when bundler logic changes in a way that must invalidate caches. */
187
- const BUNDLER_REV = 9; // 9: cache keys also cover dependency-resolution metadata
334
+ const BUNDLER_REV = 10; // 10: wrappers import the project's Firebase SDK by URL; production keys cover it
188
335
 
189
336
  const RESOLUTION_FILES = [
190
337
  'bun.lock',
@@ -262,12 +409,23 @@ export function cacheKey(
262
409
  h.update(name);
263
410
  h.update(readFileSync(file, 'utf8'));
264
411
  }
412
+ // Production AI mode also serves the project's Firebase SDK, so upgrading
413
+ // `firebase`, or a project resolving another copy, rebuilds it.
414
+ const projectFirebase = opts.projectFirebase;
415
+ if (projectFirebase) {
416
+ const firebase = resolveProjectFirebase(projectFirebase.projectDir);
417
+ h.update(`firebase-sdk\0${firebase.root}\0${firebase.version}`);
418
+ }
265
419
  return `${version}-${h.digest('hex').slice(0, 12)}`;
266
420
  }
267
421
 
268
422
  // ─── the bundle step ──────────────────────────────────────────────────
269
423
 
270
424
  export async function bundleSdk(opts: BundleOptions): Promise<BundleResult> {
425
+ // Resolved first, so a project without `firebase` fails before any build.
426
+ const projectFirebase = opts.projectFirebase
427
+ ? { ...opts.projectFirebase, firebase: resolveProjectFirebase(opts.projectFirebase.projectDir) }
428
+ : undefined;
271
429
  const root = pyricPackageRoot();
272
430
  const version = pyricVersion(root);
273
431
  const key = cacheKey(opts, version);
@@ -317,7 +475,7 @@ export async function bundleSdk(opts: BundleOptions): Promise<BundleResult> {
317
475
  js: '/* pyric sandbox shim serving firebase/*. This is not the real Firebase SDK. */',
318
476
  },
319
477
  external: ['firebase/*'],
320
- plugins: [pyricResolvePlugin(), nodeShimPlugin()],
478
+ plugins: [pyricResolvePlugin(), nodeShimPlugin(), bridgeImportPlugin()],
321
479
  });
322
480
  const hasBuildErrors = result.errors.length > 0;
323
481
  if (hasBuildErrors) {
@@ -325,6 +483,9 @@ export async function bundleSdk(opts: BundleOptions): Promise<BundleResult> {
325
483
  `pyric sandbox: SDK bundle failed:\n${result.errors.map((e) => e.text).join('\n')}`,
326
484
  );
327
485
  }
486
+ if (projectFirebase) {
487
+ await bundleProjectFirebase(outDir, projectFirebase.projectDir, projectFirebase.firebase, opts.minify ?? true);
488
+ }
328
489
  writeFileSync(join(outDir, '.complete'), new Date().toISOString());
329
490
  const files = readdirSync(outDir)
330
491
  .filter((f) => f.endsWith('.js'))
@@ -2,68 +2,18 @@
2
2
  import { bindAppRegistrySandbox } from 'pyric/app/internal';
3
3
  import { sandbox } from './app-backend.js';
4
4
  import * as pyricApp from 'pyric/app';
5
- import * as realApp from 'firebase/app';
5
+ import { shadowedAppRegistry } from '../../register/app-bridge-shadow.js';
6
6
 
7
7
  bindAppRegistrySandbox(sandbox);
8
8
 
9
- function decorateApp(
10
- app: pyricApp.FirebaseApp,
11
- options?: pyricApp.FirebaseOptions,
12
- rawSettings?: string | pyricApp.FirebaseAppSettings,
13
- ): pyricApp.FirebaseApp {
14
- try {
15
- let shadow: { container?: unknown };
16
- try {
17
- shadow = realApp.getApp(app.name) as { container?: unknown };
18
- } catch {
19
- const initOptions = (options ?? app.options) as realApp.FirebaseOptions;
20
- const initSettings = rawSettings ?? app.name;
21
- const isStringSettings = typeof initSettings === 'string';
22
- if (isStringSettings) {
23
- shadow = realApp.initializeApp(initOptions, initSettings) as { container?: unknown };
24
- } else {
25
- const objectSettings = initSettings as realApp.FirebaseAppSettings;
26
- shadow = realApp.initializeApp(initOptions, objectSettings) as { container?: unknown };
27
- }
28
- }
29
- const hasShadowContainer = shadow.container !== undefined && shadow.container !== null;
30
- if (hasShadowContainer) {
31
- Object.defineProperty(app, 'container', { value: shadow.container, configurable: true });
32
- }
33
- } catch {
34
- // Observational/best-effort: do not break Pyric initialization if shadow initialization fails.
35
- }
36
- return app;
37
- }
9
+ // One shadow implementation for every plane: the register adapters build
10
+ // their exports from the same registry.
11
+ const registry = shadowedAppRegistry(pyricApp);
38
12
 
39
- export function initializeApp(
40
- options?: pyricApp.FirebaseOptions,
41
- rawSettings?: string | pyricApp.FirebaseAppSettings,
42
- ): pyricApp.FirebaseApp {
43
- const app = pyricApp.initializeApp(options, rawSettings);
44
- return decorateApp(app, options, rawSettings);
45
- }
46
-
47
- export function getApp(name?: string): pyricApp.FirebaseApp {
48
- return decorateApp(pyricApp.getApp(name));
49
- }
50
-
51
- export function getApps(): pyricApp.FirebaseApp[] {
52
- return pyricApp.getApps().map((app) => decorateApp(app));
53
- }
54
-
55
- export async function deleteApp(app: pyricApp.FirebaseApp): Promise<void> {
56
- try {
57
- const shadow = realApp.getApp(app.name);
58
- const hasShadow = shadow !== undefined && shadow !== null;
59
- if (hasShadow) {
60
- await realApp.deleteApp(shadow);
61
- }
62
- } catch {
63
- // Ignore if shadow app was not initialized or already deleted.
64
- }
65
- return pyricApp.deleteApp(app);
66
- }
13
+ export const initializeApp = registry.initializeApp;
14
+ export const getApp = registry.getApp;
15
+ export const getApps = registry.getApps;
16
+ export const deleteApp = registry.deleteApp;
67
17
 
68
18
  export {
69
19
  FirebaseError,
@@ -8,7 +8,8 @@
8
8
  * container.
9
9
  *
10
10
  * Every plane that swaps Firebase modules reads {@link swappedFirebaseEntries}:
11
- * the `pyric sandbox` import map (`html-injection.ts`) and the Vite resolver
11
+ * the `pyric sandbox` import map (`html-injection.ts`, through
12
+ * {@link importMapFirebaseEntries}) and the Vite resolver
12
13
  * (`vite-module-swap.ts`). Every front door resolves the mode with
13
14
  * {@link resolveAiMode}. One table and one precedence rule, so the planes
14
15
  * cannot disagree about what production mode means. It imports only Node
@@ -78,6 +79,34 @@ export function swappedFirebaseEntries(mode: AiMode): ReadonlyMap<ServedFirebase
78
79
  return swapped;
79
80
  }
80
81
 
82
+ /**
83
+ * The Firebase SDK modules `pyric sandbox` builds for the browser from the
84
+ * project's own `firebase` install in production AI mode, to the entry key of
85
+ * each (`/__pyric/sdk/<key>.js`). One build, so the three share one
86
+ * `@firebase/app` component registry. The Vite and register planes resolve
87
+ * the Firebase SDK themselves and never read this.
88
+ */
89
+ export const REAL_SDK_ENTRIES: ReadonlyMap<ServedFirebaseSpecifier, string> = new Map([
90
+ ['firebase/app', 'firebase-sdk-app'],
91
+ ['firebase/ai', 'firebase-sdk-ai'],
92
+ ['firebase/app-check', 'firebase-sdk-app-check'],
93
+ ]);
94
+
95
+ /**
96
+ * The import-map plane's specifier to entry key table: the swap, plus, in
97
+ * production AI mode, the unswapped `firebase/ai` sent to the Firebase SDK
98
+ * that `pyric sandbox` builds. A browser has no package resolution, so on
99
+ * this plane an unswapped specifier still needs a URL.
100
+ */
101
+ export function importMapFirebaseEntries(mode: AiMode): ReadonlyMap<ServedFirebaseSpecifier, string> {
102
+ const entries = new Map(swappedFirebaseEntries(mode));
103
+ for (const [specifier, key] of REAL_SDK_ENTRIES) {
104
+ const isUnswapped = !entries.has(specifier);
105
+ if (isUnswapped) entries.set(specifier, key);
106
+ }
107
+ return entries;
108
+ }
109
+
81
110
  /** This package's root (`src/serve/` and `dist/serve/` both sit two levels below it), as written and resolved. */
82
111
  const PACKAGE_ROOTS: readonly string[] = (() => {
83
112
  const root = fileURLToPath(new URL('../../', import.meta.url)).replaceAll('\\', '/');
@@ -110,10 +139,30 @@ export function isShadowAppImporter(importer: string | undefined): boolean {
110
139
  return PACKAGE_ROOTS.some((root) => file.startsWith(root) && SHADOW_MODULE.test(file.slice(root.length)));
111
140
  }
112
141
 
113
- /** The SDK modules a shadow-app wrapper imports for itself. */
114
- const BRIDGED_SPECIFIERS: ReadonlySet<string> = new Set(['firebase/app', 'firebase/app-check']);
142
+ /**
143
+ * The SDK modules a shadow-app wrapper imports for itself. The firebase-admin
144
+ * pair is the register's App Check verifier for production AI mode
145
+ * (`register/app-bridge-admin-app-check.ts`); no browser plane serves them.
146
+ */
147
+ const BRIDGED_SPECIFIERS: ReadonlySet<string> = new Set([
148
+ 'firebase/app',
149
+ 'firebase/app-check',
150
+ 'firebase-admin/app',
151
+ 'firebase-admin/app-check',
152
+ ]);
115
153
 
116
154
  /** Whether this import is a shadow-app wrapper reaching the Firebase SDK it wraps. */
117
155
  export function isBypassedBridgeImport(specifier: string, importer: string | undefined): boolean {
118
156
  return BRIDGED_SPECIFIERS.has(specifier) && isShadowAppImporter(importer);
119
157
  }
158
+
159
+ /**
160
+ * Where a bypassed bridge import goes on the import-map plane: the entry key
161
+ * of the Firebase SDK module `pyric sandbox` builds. The bare specifier
162
+ * cannot stay, because the import map sends it back to the wrapper itself.
163
+ * Undefined for every import {@link isBypassedBridgeImport} does not name.
164
+ */
165
+ export function bridgeImportEntryKey(specifier: string, importer: string | undefined): string | undefined {
166
+ if (!isBypassedBridgeImport(specifier, importer)) return undefined;
167
+ return REAL_SDK_ENTRIES.get(specifier as ServedFirebaseSpecifier);
168
+ }
@@ -2,15 +2,17 @@ import { stampHostedTarget } from './runtime/hosted-target.js';
2
2
  import type { OwnersConfig } from './owners-config.js';
3
3
  import { REACT_BOOTSTRAP } from './react-bootstrap.js';
4
4
  import { SANDBOX_BUILD_MARKER } from './sandbox-marker.js';
5
- import { swappedFirebaseEntries, type AiMode } from './firebase-module-swap.js';
5
+ import { importMapFirebaseEntries, type AiMode } from './firebase-module-swap.js';
6
6
 
7
7
  /**
8
8
  * The import-map targets, specifier to served URL, for one AI mode. Derived
9
9
  * from the shared swap table, so the Vite resolver serves the same modules.
10
+ * In production AI mode `firebase/ai` goes to the Firebase SDK that
11
+ * `pyric sandbox` builds from the project's `firebase` install.
10
12
  */
11
13
  export function sdkImportMap(mode: AiMode = 'sandbox'): Record<string, string> {
12
14
  const imports: Record<string, string> = {};
13
- for (const [specifier, entryKey] of swappedFirebaseEntries(mode)) {
15
+ for (const [specifier, entryKey] of importMapFirebaseEntries(mode)) {
14
16
  imports[specifier] = `/__pyric/sdk/${entryKey}.js`;
15
17
  }
16
18
  return imports;
@@ -731,7 +731,11 @@ export function createPyricNamespace(opts: NamespaceOptions) {
731
731
  const file = join(opts.sdkDir, basename(url.pathname));
732
732
  const missingFile = !existsSync(file);
733
733
  if (missingFile) {
734
- res.writeHead(404).end('not found');
734
+ const isRealSdk = basename(url.pathname).startsWith('firebase-sdk-');
735
+ const body = isRealSdk
736
+ ? 'pyric sandbox: the Firebase SDK was not built. Install firebase where pyric starts (production AI mode).'
737
+ : 'not found';
738
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end(body);
735
739
  return true;
736
740
  }
737
741
  const isSourceMap = file.endsWith('.map');