@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.
- package/dist/assurance/.generated/conformance-verdicts.d.ts +4 -0
- package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
- package/dist/assurance/.generated/conformance-verdicts.js +4 -0
- package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.d.ts.map +1 -1
- package/dist/cli/serve.js +13 -2
- package/dist/cli/serve.js.map +1 -1
- package/dist/conformance/.generated/can-i-use-browser.js +2 -2
- package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
- package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use.js +1 -1
- package/dist/conformance/.generated/can-i-use.js.map +1 -1
- package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
- package/dist/conformance/.generated/conformance-docs.js +1 -1
- package/dist/conformance/.generated/conformance-docs.js.map +1 -1
- package/dist/register/app-bridge-admin-app-check.d.ts +3 -0
- package/dist/register/app-bridge-admin-app-check.d.ts.map +1 -0
- package/dist/register/app-bridge-admin-app-check.js +43 -0
- package/dist/register/app-bridge-admin-app-check.js.map +1 -0
- package/dist/register/app-bridge-app-check.d.ts +4 -0
- package/dist/register/app-bridge-app-check.d.ts.map +1 -1
- package/dist/register/app-bridge-app-check.js +4 -0
- package/dist/register/app-bridge-app-check.js.map +1 -1
- package/dist/register/app-bridge-shadow.d.ts +4 -2
- package/dist/register/app-bridge-shadow.d.ts.map +1 -1
- package/dist/register/app-bridge-shadow.js +29 -5
- package/dist/register/app-bridge-shadow.js.map +1 -1
- package/dist/register/index.d.ts.map +1 -1
- package/dist/register/index.js +21 -0
- package/dist/register/index.js.map +1 -1
- package/dist/serve/bundler.d.ts +40 -0
- package/dist/serve/bundler.d.ts.map +1 -1
- package/dist/serve/bundler.js +133 -2
- package/dist/serve/bundler.js.map +1 -1
- package/dist/serve/entries/app-ai-passthrough.d.ts +4 -4
- package/dist/serve/entries/app-ai-passthrough.d.ts.map +1 -1
- package/dist/serve/entries/app-ai-passthrough.js +8 -52
- package/dist/serve/entries/app-ai-passthrough.js.map +1 -1
- package/dist/serve/firebase-module-swap.d.ts +22 -0
- package/dist/serve/firebase-module-swap.d.ts.map +1 -1
- package/dist/serve/firebase-module-swap.js +51 -3
- package/dist/serve/firebase-module-swap.js.map +1 -1
- package/dist/serve/hosted/method-protocol.d.ts +4 -4
- package/dist/serve/html-injection.d.ts +2 -0
- package/dist/serve/html-injection.d.ts.map +1 -1
- package/dist/serve/html-injection.js +4 -2
- package/dist/serve/html-injection.js.map +1 -1
- package/dist/serve/namespace.d.ts.map +1 -1
- package/dist/serve/namespace.js +5 -1
- package/dist/serve/namespace.js.map +1 -1
- package/dist/serve/site-ui/docs/ai-compat/index.html +9 -9
- package/dist/serve/site-ui/docs/ai-compat.md +9 -9
- package/dist/serve/site-ui/docs/app-check-compat/index.html +42 -18
- package/dist/serve/site-ui/docs/app-check-compat.md +42 -18
- package/dist/serve/site-ui/docs/auth-compat/index.html +4 -4
- package/dist/serve/site-ui/docs/auth-compat.md +4 -4
- package/dist/serve/site-ui/docs/build/ai-logic/index.html +2 -0
- package/dist/serve/site-ui/docs/build/ai-logic.md +4 -0
- package/dist/serve/site-ui/docs/database-compat/index.html +4 -4
- package/dist/serve/site-ui/docs/database-compat.md +4 -4
- package/dist/serve/site-ui/docs/firestore-compat/index.html +4 -4
- package/dist/serve/site-ui/docs/firestore-compat.md +4 -4
- package/dist/serve/site-ui/docs/messaging-compat/index.html +4 -4
- package/dist/serve/site-ui/docs/messaging-compat.md +4 -4
- package/dist/serve/site-ui/docs/pyric-admin-app-reference-api/index.html +4 -0
- package/dist/serve/site-ui/docs/pyric-admin-app-reference-api.md +5 -0
- package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api/index.html +2 -0
- package/dist/serve/site-ui/docs/pyric-cli-register-app-bridge-app-check-reference-api.md +3 -0
- package/dist/serve/site-ui/docs/storage-compat/index.html +5 -5
- package/dist/serve/site-ui/docs/storage-compat.md +5 -5
- package/dist/serve/site-ui/examples/chess/index.html +1 -1
- package/package.json +4 -4
- package/src/assurance/.generated/conformance-verdicts.ts +4 -0
- package/src/cli/serve.ts +14 -2
- package/src/conformance/.generated/can-i-use-browser.ts +2 -2
- package/src/conformance/.generated/can-i-use.ts +1 -1
- package/src/conformance/.generated/conformance-docs.ts +1 -1
- package/src/register/app-bridge-admin-app-check.ts +46 -0
- package/src/register/app-bridge-app-check.ts +4 -0
- package/src/register/app-bridge-shadow.ts +35 -5
- package/src/register/index.ts +23 -0
- package/src/serve/bundler.ts +163 -2
- package/src/serve/entries/app-ai-passthrough.ts +8 -58
- package/src/serve/firebase-module-swap.ts +52 -3
- package/src/serve/html-injection.ts +4 -2
- 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`)
|
|
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
|
-
//
|
|
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.
|
|
49
|
-
*
|
|
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;
|
package/src/register/index.ts
CHANGED
|
@@ -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
|
*
|
package/src/serve/bundler.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
5
|
+
import { shadowedAppRegistry } from '../../register/app-bridge-shadow.js';
|
|
6
6
|
|
|
7
7
|
bindAppRegistrySandbox(sandbox);
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
/**
|
|
114
|
-
|
|
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 {
|
|
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
|
|
15
|
+
for (const [specifier, entryKey] of importMapFirebaseEntries(mode)) {
|
|
14
16
|
imports[specifier] = `/__pyric/sdk/${entryKey}.js`;
|
|
15
17
|
}
|
|
16
18
|
return imports;
|
package/src/serve/namespace.ts
CHANGED
|
@@ -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
|
-
|
|
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');
|