create-pyric 0.1.0-alpha.22 → 0.1.0-alpha.25
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/bin.js +5 -1
- package/dist/bin.js.map +1 -1
- package/dist/firebase-pins.d.ts +16 -0
- package/dist/firebase-pins.d.ts.map +1 -0
- package/dist/firebase-pins.js +16 -0
- package/dist/firebase-pins.js.map +1 -0
- package/dist/package-manager.d.ts +18 -0
- package/dist/package-manager.d.ts.map +1 -0
- package/dist/package-manager.js +33 -0
- package/dist/package-manager.js.map +1 -0
- package/dist/scaffold.d.ts +3 -3
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +3 -3
- package/dist/scaffold.js.map +1 -1
- package/dist/template-nextjs.d.ts +0 -3
- package/dist/template-nextjs.d.ts.map +1 -1
- package/dist/template-nextjs.js +6 -2
- package/dist/template-nextjs.js.map +1 -1
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +68 -19
- package/dist/templates.js.map +1 -1
- package/package.json +1 -1
- package/src/bin.ts +5 -1
- package/src/firebase-pins.ts +15 -0
- package/src/package-manager.ts +38 -0
- package/src/scaffold.ts +5 -5
- package/src/template-nextjs.ts +3 -2
- package/src/templates.ts +68 -19
- package/templates/chat/.env.development +6 -0
- package/templates/chat/database.rules.json +4 -4
- package/templates/chat/functions/package.json +1 -1
- package/templates/chat/package.json +4 -2
- package/templates/chat/scaffold.json +1 -0
- package/templates/chat/scripts/check-listener-owner-build.ts +73 -0
- package/templates/chat/src/services/conversation-service.ts +6 -5
- package/templates/chat/src/services/firestore-helpers.ts +18 -0
- package/templates/chat/src/services/message-service.ts +4 -3
- package/templates/chat/src/services/presence-service.ts +4 -4
- package/templates/chat/src/ui/chat/chat-format.ts +8 -0
- package/templates/chat/src/ui/chat/chat-page.tsx +29 -126
- package/templates/chat/src/ui/chat/chat-types.ts +16 -4
- package/templates/chat/src/ui/chat/conversation-header.tsx +26 -0
- package/templates/chat/src/ui/chat/conversation-list.tsx +38 -0
- package/templates/chat/src/ui/chat/firebase-chat-gateway.ts +4 -4
- package/templates/chat/src/ui/chat/message-thread.tsx +156 -0
- package/templates/chat/src/ui/chat/presence-bar.tsx +23 -0
- package/templates/chat/vite.config.ts +6 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package manager that ran `create pyric`, so the printed next steps
|
|
3
|
+
* name the commands the user already uses.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
|
7
|
+
|
|
8
|
+
const PACKAGE_MANAGERS: readonly PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
9
|
+
|
|
10
|
+
/** A step a template writes for one package manager, such as `bun run dev`. */
|
|
11
|
+
const PACKAGE_MANAGER_STEP = /^(npm|bun) /;
|
|
12
|
+
|
|
13
|
+
/** The alternative a template names after a step, such as `# or: bun install`. */
|
|
14
|
+
const ALTERNATIVE_COMMENT = /\s+# or: .*$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reads the `npm_config_user_agent` value every package manager sets for
|
|
18
|
+
* `create` and `exec`, such as `pnpm/9.0.0 npm/? node/v22.15.0 linux x64`.
|
|
19
|
+
* Without one, the user ran the binary directly, and the docs use npm.
|
|
20
|
+
*/
|
|
21
|
+
export function detectPackageManager(userAgent: string | undefined): PackageManager {
|
|
22
|
+
const name = userAgent?.split('/')[0];
|
|
23
|
+
const known = PACKAGE_MANAGERS.find((pm) => pm === name);
|
|
24
|
+
return known ?? 'npm';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rewrites each `npm <command>` or `bun <command>` step to the same command
|
|
29
|
+
* for `pm`, and drops the `# or: ...` alternative, since the step now names
|
|
30
|
+
* the package manager in use.
|
|
31
|
+
*/
|
|
32
|
+
export function nextStepsFor(steps: readonly string[], pm: PackageManager): string[] {
|
|
33
|
+
return steps.map((step) => {
|
|
34
|
+
const isPackageManagerStep = PACKAGE_MANAGER_STEP.test(step);
|
|
35
|
+
if (!isPackageManagerStep) return step;
|
|
36
|
+
return step.replace(PACKAGE_MANAGER_STEP, `${pm} `).replace(ALTERNATIVE_COMMENT, '');
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/scaffold.ts
CHANGED
|
@@ -19,16 +19,16 @@ import type { FlagValue } from './parse-args.js';
|
|
|
19
19
|
export type { ScaffoldTemplate, TemplateName };
|
|
20
20
|
export { TEMPLATES, TEMPLATE_NAMES, isTemplateName } from './templates.js';
|
|
21
21
|
|
|
22
|
-
/** Where the scaffold's `pyric` / `@pyric/cli` deps come from. */
|
|
22
|
+
/** Where the scaffold's `pyric` / `@pyric/cli` / `@pyric/ui` deps come from. */
|
|
23
23
|
export type DepsMode = 'vendor' | 'npm';
|
|
24
24
|
|
|
25
|
-
/** Return a copy of `t` with `pyric` / `@pyric/cli` deps rewritten for `mode`. */
|
|
25
|
+
/** Return a copy of `t` with `pyric` / `@pyric/cli` / `@pyric/ui` deps rewritten for `mode`. */
|
|
26
26
|
export function applyDepsMode(
|
|
27
27
|
t: ScaffoldTemplate,
|
|
28
28
|
mode: DepsMode,
|
|
29
29
|
opts: { vendorSpecs?: Record<string, string>; version?: string | null },
|
|
30
30
|
): ScaffoldTemplate {
|
|
31
|
-
const WORKSPACE_PKGS = ['pyric', '@pyric/cli'];
|
|
31
|
+
const WORKSPACE_PKGS = ['pyric', '@pyric/cli', '@pyric/ui'];
|
|
32
32
|
const rewrite = (section: Record<string, string>): Record<string, string> => {
|
|
33
33
|
const next = { ...section };
|
|
34
34
|
for (const pkg of WORKSPACE_PKGS) {
|
|
@@ -202,7 +202,7 @@ export interface ScaffoldRequest {
|
|
|
202
202
|
depsMode?: DepsMode;
|
|
203
203
|
/** Already-rewritten template; defaults to TEMPLATES[template] (+ npm pin). */
|
|
204
204
|
effectiveTemplate?: ScaffoldTemplate;
|
|
205
|
-
/** Version pin for npm-mode `@pyric/cli` / `pyric` ranges. */
|
|
205
|
+
/** Version pin for npm-mode `@pyric/cli` / `pyric` / `@pyric/ui` ranges. */
|
|
206
206
|
pinVersion?: string | null;
|
|
207
207
|
/** Label used in human report lines (`create-pyric` or `pyric init`). */
|
|
208
208
|
commandLabel?: string;
|
|
@@ -281,7 +281,7 @@ export async function runScaffold(
|
|
|
281
281
|
merged: [],
|
|
282
282
|
skipped: [],
|
|
283
283
|
conflicts: [],
|
|
284
|
-
nextSteps:
|
|
284
|
+
nextSteps: effective.nextSteps,
|
|
285
285
|
};
|
|
286
286
|
|
|
287
287
|
const pkgPath = join(dir, 'package.json');
|
package/src/template-nextjs.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scaffold template definition for Next.js (`npm create pyric -- --template nextjs`).
|
|
3
3
|
*/
|
|
4
|
+
import { FIREBASE_ADMIN_PIN, FIREBASE_PIN } from './firebase-pins.js';
|
|
4
5
|
import type { ScaffoldTemplate } from './templates.js';
|
|
5
6
|
|
|
6
7
|
const NEXTJS_CONFIG_MJS = `import { fileURLToPath } from 'node:url';
|
|
@@ -491,8 +492,8 @@ export const NEXTJS_TEMPLATE: ScaffoldTemplate = {
|
|
|
491
492
|
start: 'next start',
|
|
492
493
|
},
|
|
493
494
|
dependencies: {
|
|
494
|
-
firebase:
|
|
495
|
-
'firebase-admin':
|
|
495
|
+
firebase: FIREBASE_PIN,
|
|
496
|
+
'firebase-admin': FIREBASE_ADMIN_PIN,
|
|
496
497
|
next: '^15.0.0',
|
|
497
498
|
react: '^19.0.0',
|
|
498
499
|
'react-dom': '^19.0.0',
|
package/src/templates.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
20
20
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { FIREBASE_PIN } from './firebase-pins.js';
|
|
22
23
|
import { NEXTJS_TEMPLATE } from './template-nextjs.js';
|
|
23
24
|
|
|
24
25
|
export const TEMPLATE_NAMES = ['web', 'node', 'static', 'chat', 'nextjs'] as const;
|
|
@@ -182,8 +183,9 @@ const WEB_INDEX_HTML = (name: string): string => `<!doctype html>
|
|
|
182
183
|
`;
|
|
183
184
|
|
|
184
185
|
const WEB_APP_JS = `// Canonical firebase/* imports. Under \`pyric sandbox\` they are served by the
|
|
185
|
-
// in-page pyric sandbox (the
|
|
186
|
-
// bundler/pipeline the same imports resolve to the real
|
|
186
|
+
// in-page pyric sandbox (no request reaches the project the config names);
|
|
187
|
+
// under any standard bundler/pipeline the same imports resolve to the real
|
|
188
|
+
// \`firebase\` package.
|
|
187
189
|
// Graduation changes where you run this code, never the code itself.
|
|
188
190
|
import { initializeApp } from 'firebase/app';
|
|
189
191
|
import {
|
|
@@ -347,7 +349,8 @@ const FIRESTORE_INDEXES = `{
|
|
|
347
349
|
// ─── node template (init v1 scaffold, carried over) ───────────────────
|
|
348
350
|
|
|
349
351
|
const NODE_APP_TS = `// Canonical Firebase imports stay unchanged between sandbox and production.
|
|
350
|
-
// \`bun run dev\`
|
|
352
|
+
// \`bun run dev\` runs this file against the Node-hosted Pyric sandbox
|
|
353
|
+
// (\`pyric sandbox --hosted\`); \`bun start\` loads Firebase.
|
|
351
354
|
import { initializeApp } from 'firebase/app';
|
|
352
355
|
import { getFirestore, collection, getDocs } from 'firebase/firestore';
|
|
353
356
|
import { seed } from './seed.ts';
|
|
@@ -371,15 +374,17 @@ snap.forEach((doc) => console.log(\` \${doc.id}:\`, doc.data()));
|
|
|
371
374
|
// Production: fill .env, deploy firestore.rules, then \`bun start\`.
|
|
372
375
|
`;
|
|
373
376
|
|
|
374
|
-
const NODE_SEED_TS = `import {
|
|
377
|
+
const NODE_SEED_TS = `import { doc, setDoc, type Firestore } from 'firebase/firestore';
|
|
375
378
|
|
|
379
|
+
// Fixed ids: the hosted sandbox keeps its data between runs, so each
|
|
380
|
+
// \`bun run dev\` rewrites the same two posts instead of adding two more.
|
|
376
381
|
export async function seed(db: Firestore): Promise<void> {
|
|
377
|
-
await
|
|
382
|
+
await setDoc(doc(db, 'posts', 'hello'), {
|
|
378
383
|
title: 'Hello, Pyric',
|
|
379
384
|
author: 'sandbox',
|
|
380
385
|
createdAt: new Date(),
|
|
381
386
|
});
|
|
382
|
-
await
|
|
387
|
+
await setDoc(doc(db, 'posts', 'local-first'), {
|
|
383
388
|
title: 'Local-first by default',
|
|
384
389
|
author: 'sandbox',
|
|
385
390
|
createdAt: new Date(),
|
|
@@ -432,7 +437,7 @@ real Firebase in production. No application-code switch is required.
|
|
|
432
437
|
|
|
433
438
|
\`\`\`bash
|
|
434
439
|
bun install
|
|
435
|
-
bun run dev # Pyric sandbox
|
|
440
|
+
bun run dev # Pyric sandbox hosted in Node (pyric sandbox --hosted)
|
|
436
441
|
bun start # production: real Firebase
|
|
437
442
|
\`\`\`
|
|
438
443
|
|
|
@@ -490,13 +495,17 @@ const VITE_INDEX_HTML = (name: string): string => `<!doctype html>
|
|
|
490
495
|
form { display: flex; gap: 0.5rem; margin: 1rem 0; }
|
|
491
496
|
input { flex: 1; padding: 0.4rem 0.6rem; }
|
|
492
497
|
ul { padding-left: 1.2rem; }
|
|
493
|
-
.status { color: #666; }
|
|
498
|
+
.status { color: #666; display: flex; align-items: center; gap: 0.5rem; }
|
|
499
|
+
.avatar { width: 32px; height: 32px; border-radius: 50%; }
|
|
494
500
|
</style>
|
|
495
501
|
</head>
|
|
496
502
|
<body>
|
|
497
503
|
<main>
|
|
498
504
|
<h1>${name}</h1>
|
|
499
|
-
<p class="status"
|
|
505
|
+
<p class="status">
|
|
506
|
+
<img class="avatar" id="avatar" alt="" hidden />
|
|
507
|
+
<span id="auth-status">Signed out</span>
|
|
508
|
+
</p>
|
|
500
509
|
<button id="sign-in">Sign in with Google</button>
|
|
501
510
|
<button id="sign-out" hidden>Sign out</button>
|
|
502
511
|
<!-- Visible even while signed out ON PURPOSE — submitting attempts the
|
|
@@ -512,10 +521,11 @@ const VITE_INDEX_HTML = (name: string): string => `<!doctype html>
|
|
|
512
521
|
</html>
|
|
513
522
|
`;
|
|
514
523
|
|
|
515
|
-
const VITE_MAIN_TS = `// Canonical firebase/* imports
|
|
524
|
+
const VITE_MAIN_TS = `// Canonical firebase/* imports, the same in dev and prod.
|
|
516
525
|
// In \`vite dev\` the \`@pyric/cli/vite\` plugin swaps these to an in-process
|
|
517
|
-
// sandbox
|
|
518
|
-
//
|
|
526
|
+
// sandbox: no request reaches the project the config below names, and the
|
|
527
|
+
// sandbox reads its projectId the way Firebase does. \`vite build\` ships the
|
|
528
|
+
// real \`firebase\` package with the same config. Graduation is a build, not
|
|
519
529
|
// a code edit.
|
|
520
530
|
import { initializeApp } from 'firebase/app';
|
|
521
531
|
import {
|
|
@@ -553,7 +563,7 @@ if (hasProjectId) {
|
|
|
553
563
|
|
|
554
564
|
const app = initializeApp({
|
|
555
565
|
// Filled from .env (see .env.example) at \`vite build\` time for production.
|
|
556
|
-
//
|
|
566
|
+
// In \`vite dev\` the pyric sandbox stands in for Firebase and reads projectId.
|
|
557
567
|
apiKey,
|
|
558
568
|
authDomain,
|
|
559
569
|
projectId,
|
|
@@ -564,6 +574,7 @@ const db = getFirestore(app);
|
|
|
564
574
|
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
|
565
575
|
const els = {
|
|
566
576
|
status: $('auth-status'),
|
|
577
|
+
avatar: $<HTMLImageElement>('avatar'),
|
|
567
578
|
signIn: $<HTMLButtonElement>('sign-in'),
|
|
568
579
|
signOut: $<HTMLButtonElement>('sign-out'),
|
|
569
580
|
form: $<HTMLFormElement>('add-post'),
|
|
@@ -579,7 +590,7 @@ let unsubscribePosts: (() => void) | undefined = undefined;
|
|
|
579
590
|
onAuthStateChanged(auth, (user) => {
|
|
580
591
|
const hasActiveSubscription = unsubscribePosts !== undefined;
|
|
581
592
|
if (hasActiveSubscription) {
|
|
582
|
-
unsubscribePosts();
|
|
593
|
+
unsubscribePosts?.();
|
|
583
594
|
unsubscribePosts = undefined;
|
|
584
595
|
}
|
|
585
596
|
|
|
@@ -595,6 +606,16 @@ onAuthStateChanged(auth, (user) => {
|
|
|
595
606
|
displayLabel = user.displayName as string;
|
|
596
607
|
}
|
|
597
608
|
els.status.textContent = 'Signed in as ' + displayLabel;
|
|
609
|
+
// Provider sign-ins always carry a photoURL, in the sandbox as in
|
|
610
|
+
// production; email/password and anonymous users have none.
|
|
611
|
+
const hasPhoto = user.photoURL !== null;
|
|
612
|
+
if (hasPhoto) {
|
|
613
|
+
els.avatar.src = user.photoURL as string;
|
|
614
|
+
els.avatar.hidden = false;
|
|
615
|
+
} else {
|
|
616
|
+
els.avatar.src = '';
|
|
617
|
+
els.avatar.hidden = true;
|
|
618
|
+
}
|
|
598
619
|
els.signIn.hidden = true;
|
|
599
620
|
els.signOut.hidden = false;
|
|
600
621
|
|
|
@@ -616,6 +637,8 @@ onAuthStateChanged(auth, (user) => {
|
|
|
616
637
|
});
|
|
617
638
|
} else {
|
|
618
639
|
els.status.textContent = 'Signed out';
|
|
640
|
+
els.avatar.hidden = true;
|
|
641
|
+
els.avatar.src = '';
|
|
619
642
|
els.signIn.hidden = false;
|
|
620
643
|
els.signOut.hidden = true;
|
|
621
644
|
els.posts.replaceChildren();
|
|
@@ -671,8 +694,34 @@ import { pyric } from '@pyric/cli/vite';
|
|
|
671
694
|
// self-contained sandbox preview you can serve under \`pyric sandbox\`, build with a
|
|
672
695
|
// non-production mode: \`vite build --mode development\` (see the \`build:sandbox\`
|
|
673
696
|
// script). That output is marked and can never be deployed.
|
|
697
|
+
|
|
698
|
+
// A custom avatar generator lives in its own file so this config stays
|
|
699
|
+
// small. The pyric example app ships vite.avatars.ts with a worked example
|
|
700
|
+
// that calls an image API; write your own function of the same shape to
|
|
701
|
+
// use the \`avatars.source\` option below.
|
|
702
|
+
// import { nanoBananaAvatar } from './vite.avatars';
|
|
703
|
+
|
|
674
704
|
export default defineConfig({
|
|
675
|
-
plugins: [
|
|
705
|
+
plugins: [
|
|
706
|
+
pyric({
|
|
707
|
+
// Profile photos for provider sign-ins. The default needs no
|
|
708
|
+
// configuration: every federated sign-in gets a deterministic
|
|
709
|
+
// generated avatar. Comment exactly one option back in to try the
|
|
710
|
+
// alternatives.
|
|
711
|
+
|
|
712
|
+
// Firebase-null behaviour — photoURL stays null and the avatar
|
|
713
|
+
// route unmounts:
|
|
714
|
+
// avatars: false,
|
|
715
|
+
|
|
716
|
+
// A pre-created avatar set: a directory holding a manifest.json and
|
|
717
|
+
// image files; each user is deterministically assigned one:
|
|
718
|
+
// avatars: './avatars',
|
|
719
|
+
|
|
720
|
+
// Generate each user's photo once, on demand, with the function
|
|
721
|
+
// named in the commented import above:
|
|
722
|
+
// avatars: { source: nanoBananaAvatar },
|
|
723
|
+
}),
|
|
724
|
+
],
|
|
676
725
|
});
|
|
677
726
|
`;
|
|
678
727
|
|
|
@@ -777,7 +826,7 @@ export const TEMPLATES: Record<TemplateName, ScaffoldTemplate> = {
|
|
|
777
826
|
},
|
|
778
827
|
// The real firebase package ships day one so the production `vite build`
|
|
779
828
|
// resolves the same canonical imports against it — no code edit at graduation.
|
|
780
|
-
dependencies: { firebase:
|
|
829
|
+
dependencies: { firebase: FIREBASE_PIN },
|
|
781
830
|
devDependencies: { '@pyric/cli': '*', vite: '^6.0.0', typescript: '^5.7.0' },
|
|
782
831
|
dirs: ['src'],
|
|
783
832
|
files: (name) => [
|
|
@@ -802,10 +851,10 @@ export const TEMPLATES: Record<TemplateName, ScaffoldTemplate> = {
|
|
|
802
851
|
node: {
|
|
803
852
|
scripts: {
|
|
804
853
|
start: 'node --env-file-if-exists=.env --experimental-strip-types src/app.ts',
|
|
805
|
-
dev: 'pyric sandbox --no-open -- node --env-file-if-exists=.env --experimental-strip-types src/app.ts',
|
|
854
|
+
dev: 'pyric sandbox --hosted --no-open -- node --env-file-if-exists=.env --experimental-strip-types src/app.ts',
|
|
806
855
|
bridge: 'pyric bridge',
|
|
807
856
|
},
|
|
808
|
-
dependencies: { firebase:
|
|
857
|
+
dependencies: { firebase: FIREBASE_PIN },
|
|
809
858
|
devDependencies: { '@pyric/cli': '*', '@types/node': '^22.0.0', typescript: '^5.7.0' },
|
|
810
859
|
dirs: ['src'],
|
|
811
860
|
files: (name) => [
|
|
@@ -828,7 +877,7 @@ export const TEMPLATES: Record<TemplateName, ScaffoldTemplate> = {
|
|
|
828
877
|
dev: 'pyric sandbox --seed seed.json',
|
|
829
878
|
'dev:agent': 'pyric sandbox --bridge --seed seed.json',
|
|
830
879
|
},
|
|
831
|
-
dependencies: { firebase:
|
|
880
|
+
dependencies: { firebase: FIREBASE_PIN },
|
|
832
881
|
devDependencies: { '@pyric/cli': '*' },
|
|
833
882
|
dirs: ['public'],
|
|
834
883
|
files: (name) => [
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Loaded by Vite for `--mode development`, which includes the served sandbox
|
|
2
|
+
# build (`npm run build:sandbox`). Vite otherwise treats every `vite build` as
|
|
3
|
+
# NODE_ENV=production, which turns off pyric's listener attribution and the
|
|
4
|
+
# React component capture behind it. Development is also what the plain
|
|
5
|
+
# `vite dev` path runs as, so the two sandbox paths behave the same.
|
|
6
|
+
NODE_ENV=development
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"rules": {
|
|
3
3
|
"presence": {
|
|
4
|
-
".read": "auth
|
|
4
|
+
".read": "auth != null",
|
|
5
5
|
"$uid": {
|
|
6
|
-
".write": "auth
|
|
6
|
+
".write": "auth != null && auth.uid == $uid",
|
|
7
7
|
".validate": "newData.hasChildren(['state', 'at']) && newData.child('state').isString()"
|
|
8
8
|
}
|
|
9
9
|
},
|
|
10
10
|
"notify": {
|
|
11
11
|
"$uid": {
|
|
12
|
-
".read": "auth
|
|
13
|
-
".write": "auth
|
|
12
|
+
".read": "auth != null && auth.uid == $uid",
|
|
13
|
+
".write": "auth != null && auth.uid == $uid",
|
|
14
14
|
"$pushId": {
|
|
15
15
|
".validate": "newData.hasChildren(['title', 'body']) && newData.child('title').isString() && newData.child('body').isString()"
|
|
16
16
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"build:sandbox": "vite build --mode development",
|
|
10
10
|
"test": "node --experimental-strip-types --test test/*.test.*",
|
|
11
11
|
"test:notifications": "npm run build && node --experimental-strip-types scripts/check-notification-build.ts",
|
|
12
|
+
"test:listener-owner": "node --experimental-strip-types scripts/check-listener-owner-build.ts",
|
|
12
13
|
"preview": "vite preview",
|
|
13
14
|
"typecheck": "tsc --noEmit",
|
|
14
15
|
"rules:resolve": "pyric firestore rules resolve firestore.modules.rules --out firestore.rules"
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"class-variance-authority": "^0.7.1",
|
|
26
27
|
"clsx": "^2.1.1",
|
|
27
28
|
"esbuild-wasm": "^0.25.0",
|
|
28
|
-
"firebase": "
|
|
29
|
+
"firebase": "~12.13.0",
|
|
29
30
|
"lucide-react": "^1.25.0",
|
|
30
31
|
"react": "^19.2.7",
|
|
31
32
|
"react-dom": "^19.2.7",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@pyric/cli": "*",
|
|
41
|
+
"@pyric/ui": "*",
|
|
40
42
|
"@tailwindcss/vite": "^4.3.3",
|
|
41
43
|
"@types/react": "^19.2.17",
|
|
42
44
|
"@types/react-dom": "^19.2.3",
|
|
@@ -46,7 +48,7 @@
|
|
|
46
48
|
"typescript": "^5.7.0",
|
|
47
49
|
"vite": "^6.0.0",
|
|
48
50
|
"pyric": "*",
|
|
49
|
-
"firebase-admin": "
|
|
51
|
+
"firebase-admin": "~13.10.0",
|
|
50
52
|
"firebase-functions": "^6.0.0"
|
|
51
53
|
}
|
|
52
54
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proves that listener attribution costs the production build nothing.
|
|
3
|
+
*
|
|
4
|
+
* The app calls `useListenerOwner` from `@pyric/ui/listener-owner` to name
|
|
5
|
+
* the React component that owns each Firestore listener. That hook gates its
|
|
6
|
+
* capture on `process.env.NODE_ENV`, and `@pyric/cli/vite` swaps `firebase/*`
|
|
7
|
+
* to pyric only outside production mode. Both claims are checked here by
|
|
8
|
+
* building the app twice and reading the emitted JavaScript.
|
|
9
|
+
*
|
|
10
|
+
* Two markers are read:
|
|
11
|
+
*
|
|
12
|
+
* - `pyric`, which appears throughout the sandbox implementation. A
|
|
13
|
+
* production bundle must not contain it anywhere.
|
|
14
|
+
* - the string `/node_modules/`, the frame filter the hook uses when it reads
|
|
15
|
+
* a render-phase call stack. It is present only when the capture survived
|
|
16
|
+
* the build, so it stands in for the capture itself.
|
|
17
|
+
*
|
|
18
|
+
* A production build must carry neither. A development-mode build, the one
|
|
19
|
+
* `npm run build:sandbox` produces for serving under `pyric sandbox`, must
|
|
20
|
+
* carry both, which is what makes the production result evidence of the gate
|
|
21
|
+
* rather than of a marker that never existed.
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const PYRIC_MARKER = 'pyric';
|
|
29
|
+
const CAPTURE_MARKER = '/node_modules/';
|
|
30
|
+
|
|
31
|
+
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const templateDirectory = join(scriptDirectory, '..');
|
|
33
|
+
|
|
34
|
+
/** Build the app in `mode` into its own directory and return the emitted
|
|
35
|
+
* application JavaScript, the service worker excluded. */
|
|
36
|
+
async function buildAndRead(mode: string, outDirectory: string): Promise<string> {
|
|
37
|
+
execFileSync(
|
|
38
|
+
'npx',
|
|
39
|
+
['vite', 'build', '--mode', mode, '--emptyOutDir', '--outDir', outDirectory],
|
|
40
|
+
{ cwd: templateDirectory, stdio: 'inherit' },
|
|
41
|
+
);
|
|
42
|
+
const assetsDirectory = join(templateDirectory, outDirectory, 'assets');
|
|
43
|
+
const names = (await readdir(assetsDirectory)).filter(
|
|
44
|
+
(name) => name.endsWith('.js') && !name.startsWith('firebase-messaging-sw-'),
|
|
45
|
+
);
|
|
46
|
+
if (names.length === 0) {
|
|
47
|
+
throw new Error(`The ${mode} build emitted no application JavaScript.`);
|
|
48
|
+
}
|
|
49
|
+
const sources = await Promise.all(
|
|
50
|
+
names.map((name) => readFile(join(assetsDirectory, name), 'utf8')),
|
|
51
|
+
);
|
|
52
|
+
return sources.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const production = await buildAndRead('production', 'dist/listener-owner-check/production');
|
|
56
|
+
|
|
57
|
+
if (production.includes(PYRIC_MARKER)) {
|
|
58
|
+
throw new Error('The production bundle contains pyric.');
|
|
59
|
+
}
|
|
60
|
+
if (production.includes(CAPTURE_MARKER)) {
|
|
61
|
+
throw new Error('The production bundle contains the listener-owner stack capture.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const development = await buildAndRead('development', 'dist/listener-owner-check/development');
|
|
65
|
+
|
|
66
|
+
if (!development.includes(PYRIC_MARKER)) {
|
|
67
|
+
throw new Error('The development-mode bundle does not contain pyric, so the production result proves nothing.');
|
|
68
|
+
}
|
|
69
|
+
if (!development.includes(CAPTURE_MARKER)) {
|
|
70
|
+
throw new Error('The development-mode bundle does not contain the listener-owner stack capture, so the production result proves nothing.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log('Listener owner build checks passed: pyric and the stack capture are in the development build and absent from the production build.');
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type Query,
|
|
17
17
|
type QueryDocumentSnapshot,
|
|
18
18
|
type QuerySnapshot,
|
|
19
|
+
type SnapshotListenOptions,
|
|
19
20
|
} from 'firebase/firestore';
|
|
20
21
|
import { auth, db } from '../firebase/app';
|
|
21
22
|
import {
|
|
@@ -28,7 +29,7 @@ import {
|
|
|
28
29
|
type PageOptions,
|
|
29
30
|
ServiceError,
|
|
30
31
|
} from '../firebase/types';
|
|
31
|
-
import { clampPageSize, mapFirestoreError, requireUid } from './firestore-helpers';
|
|
32
|
+
import { clampPageSize, listenOptions, mapFirestoreError, requireUid, type ListenerOptions } from './firestore-helpers';
|
|
32
33
|
|
|
33
34
|
const allowedModels = new Set(['gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-flash', 'gemini-flash-lite-latest']);
|
|
34
35
|
const cleanTitle = (title: string): string => title.trim().slice(0, 120) || 'New conversation';
|
|
@@ -59,10 +60,10 @@ export class ConversationService {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
observeList(callback: (conversations: Conversation[]) => void, options: PageOptions = {}): () => void {
|
|
63
|
+
observeList(callback: (conversations: Conversation[]) => void, options: PageOptions & ListenerOptions = {}): () => void {
|
|
63
64
|
const uid = requireUid(auth.currentUser?.uid);
|
|
64
65
|
const pageSize = clampPageSize(options.pageSize);
|
|
65
|
-
return onSnapshot(conversationQuery(uid, pageSize), (snapshot) => callback(snapshot.docs.map(toConversation)));
|
|
66
|
+
return onSnapshot(conversationQuery(uid, pageSize), listenOptions<SnapshotListenOptions>(options), (snapshot) => callback(snapshot.docs.map(toConversation)));
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
async create(input: CreateConversationInput = {}): Promise<Conversation['id']> {
|
|
@@ -146,9 +147,9 @@ export class ConversationService {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
observe(id: Conversation['id'], callback: (value: Conversation) => void): () => void {
|
|
150
|
+
observe(id: Conversation['id'], callback: (value: Conversation) => void, options?: ListenerOptions): () => void {
|
|
150
151
|
requireUid(auth.currentUser?.uid);
|
|
151
|
-
return onSnapshot(doc(db, 'conversations', id), (snapshot) => {
|
|
152
|
+
return onSnapshot(doc(db, 'conversations', id), listenOptions<SnapshotListenOptions>(options), (snapshot) => {
|
|
152
153
|
if (snapshot.exists()) callback({ id, ...(snapshot.data() as ConversationDocument) });
|
|
153
154
|
});
|
|
154
155
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ListenerOwner } from '@pyric/ui/listener-owner';
|
|
1
2
|
import {
|
|
2
3
|
doc,
|
|
3
4
|
type DocumentData,
|
|
@@ -37,3 +38,20 @@ export const typedDoc = <T extends DocumentData>(firestore: Firestore, path: str
|
|
|
37
38
|
|
|
38
39
|
export const clampPageSize = (value: number | undefined, fallback = 50): number =>
|
|
39
40
|
Math.min(Math.max(Math.trunc(value ?? fallback), 1), 100);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Who owns a listener: either a region name the caller gives it, or the
|
|
44
|
+
* React component `useListenerOwner` identified. Under the local sandbox the
|
|
45
|
+
* SDK records this as the listener's owner, which is how the runtime chip and
|
|
46
|
+
* Studio group listeners by the UI they feed. The production SDK reads only
|
|
47
|
+
* its own keys from the listen options and ignores the owner.
|
|
48
|
+
*
|
|
49
|
+
* `ListenerOwner` is a type-only import. `@pyric/ui` is a development
|
|
50
|
+
* dependency and none of it reaches the production build.
|
|
51
|
+
*/
|
|
52
|
+
export type ListenerOptions = { owner?: string | ListenerOwner };
|
|
53
|
+
|
|
54
|
+
/** The listen options object to hand the SDK, typed as whatever the SDK expects so the extra key passes. */
|
|
55
|
+
export function listenOptions<T extends object>(options: ListenerOptions | undefined): T {
|
|
56
|
+
return (options?.owner === undefined ? {} : { owner: options.owner }) as T;
|
|
57
|
+
}
|
|
@@ -11,11 +11,12 @@ import {
|
|
|
11
11
|
serverTimestamp,
|
|
12
12
|
startAfter,
|
|
13
13
|
where,
|
|
14
|
+
type SnapshotListenOptions,
|
|
14
15
|
} from 'firebase/firestore';
|
|
15
16
|
import { push, ref, serverTimestamp as rtdbServerTimestamp } from 'firebase/database';
|
|
16
17
|
import { auth, db, rtdb } from '../firebase/app';
|
|
17
18
|
import { asMessageId, type AppendAssistantMessageInput, type AppendUserMessageInput, type Message, type MessageDocument, type Page, type PageOptions, ServiceError } from '../firebase/types';
|
|
18
|
-
import { clampPageSize, mapFirestoreError, requireUid } from './firestore-helpers';
|
|
19
|
+
import { clampPageSize, mapFirestoreError, requireUid, listenOptions, type ListenerOptions } from './firestore-helpers';
|
|
19
20
|
|
|
20
21
|
const toMessage = (snapshot: { id: string; data: () => unknown }): Message => ({
|
|
21
22
|
id: asMessageId(snapshot.id),
|
|
@@ -144,9 +145,9 @@ export class MessageService {
|
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
observeRecent(conversationId: string, callback: (messages: Message[]) => void): () => void {
|
|
148
|
+
observeRecent(conversationId: string, callback: (messages: Message[]) => void, options?: ListenerOptions): () => void {
|
|
148
149
|
const uid = requireUid(auth.currentUser?.uid);
|
|
149
150
|
const messageQuery = query(messageCollection(conversationId), where('ownerUid', '==', uid), orderBy('createdAt', 'desc'), limit(50));
|
|
150
|
-
return onSnapshot(messageQuery, (snapshot) => callback(snapshot.docs.map(toMessage).reverse()));
|
|
151
|
+
return onSnapshot(messageQuery, listenOptions<SnapshotListenOptions>(options), (snapshot) => callback(snapshot.docs.map(toMessage).reverse()));
|
|
151
152
|
}
|
|
152
153
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { onDisconnect, onValue, ref, serverTimestamp, set } from 'firebase/database';
|
|
1
|
+
import { onDisconnect, onValue, ref, serverTimestamp, set, type ListenOptions } from 'firebase/database';
|
|
2
2
|
import { auth, rtdb } from '../firebase/app';
|
|
3
3
|
import { asUserId, type AuthUser, type PresenceEntry, type PresenceRecord, ServiceError } from '../firebase/types';
|
|
4
|
-
import { requireUid } from './firestore-helpers';
|
|
4
|
+
import { listenOptions, requireUid, type ListenerOptions } from './firestore-helpers';
|
|
5
5
|
|
|
6
6
|
const mapRtdbError = (error: unknown): ServiceError => {
|
|
7
7
|
const code = typeof error === 'object' && error && 'code' in error ? String(error.code) : '';
|
|
@@ -50,7 +50,7 @@ export class PresenceService {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Observe every presence node; the callback receives the online members. */
|
|
53
|
-
observe(callback: (online: PresenceEntry[]) => void): () => void {
|
|
53
|
+
observe(callback: (online: PresenceEntry[]) => void, options?: ListenerOptions): () => void {
|
|
54
54
|
requireUid(auth.currentUser?.uid);
|
|
55
55
|
return onValue(ref(rtdb, 'presence'), (snapshot) => {
|
|
56
56
|
const entries: PresenceEntry[] = [];
|
|
@@ -59,6 +59,6 @@ export class PresenceService {
|
|
|
59
59
|
if (entry && entry.state === 'online') entries.push(entry);
|
|
60
60
|
});
|
|
61
61
|
callback(entries);
|
|
62
|
-
});
|
|
62
|
+
}, listenOptions<ListenOptions>(options));
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { UiUser } from './chat-types';
|
|
2
|
+
|
|
3
|
+
/** The initials shown in an avatar for a user, or for "You" when there is none. */
|
|
4
|
+
export const initials = (user: UiUser | null): string =>
|
|
5
|
+
(user?.displayName ?? user?.email ?? 'You').split(' ').map((part) => part[0]).join('').slice(0, 2).toUpperCase();
|
|
6
|
+
|
|
7
|
+
/** A short local time for a conversation or message timestamp. */
|
|
8
|
+
export const formatTime = (date?: Date): string => date ? new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date) : '';
|