@pylonsync/create-pylon 0.3.54 → 0.3.57

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 (46) hide show
  1. package/bin/create-pylon.js +28 -51
  2. package/package.json +1 -1
  3. package/templates/_root/turbo.json +18 -0
  4. package/templates/backend/b2b/apps/api/package.json +3 -3
  5. package/templates/backend/barebones/apps/api/package.json +3 -3
  6. package/templates/backend/chat/apps/api/package.json +3 -3
  7. package/templates/backend/consumer/apps/api/package.json +3 -3
  8. package/templates/backend/todo/apps/api/package.json +3 -3
  9. package/templates/expo/barebones/apps/expo/package.json +1 -0
  10. package/templates/expo/chat/apps/expo/App.tsx +126 -156
  11. package/templates/expo/chat/apps/expo/package.json +1 -0
  12. package/templates/expo/consumer/apps/expo/App.tsx +211 -179
  13. package/templates/expo/consumer/apps/expo/package.json +1 -0
  14. package/templates/expo/todo/apps/expo/package.json +1 -0
  15. package/templates/ios/chat/apps/ios/Package.swift +1 -11
  16. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +26 -30
  17. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
  18. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +27 -4
  19. package/templates/ios/chat/apps/ios/project.yml +2 -0
  20. package/templates/ios/consumer/apps/ios/Package.swift +1 -0
  21. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
  22. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
  23. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
  24. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
  25. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +33 -5
  26. package/templates/ios/consumer/apps/ios/project.yml +2 -0
  27. package/templates/mac/chat/apps/mac/Package.swift +1 -0
  28. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +30 -27
  29. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
  30. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +26 -5
  31. package/templates/mac/chat/apps/mac/project.yml +2 -0
  32. package/templates/mac/consumer/apps/mac/Package.swift +1 -0
  33. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
  34. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
  35. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
  36. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
  37. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -6
  38. package/templates/mac/consumer/apps/mac/project.yml +2 -0
  39. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +52 -71
  40. package/templates/web/chat/apps/web/src/app/layout.tsx +3 -2
  41. package/templates/web/chat/apps/web/src/app/page.tsx +5 -44
  42. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  43. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +139 -119
  44. package/templates/web/consumer/apps/web/src/app/layout.tsx +3 -2
  45. package/templates/web/consumer/apps/web/src/app/page.tsx +8 -42
  46. package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
@@ -1,5 +1,6 @@
1
1
  import SwiftUI
2
2
  import PylonClient
3
+ import PylonSync
3
4
 
4
5
  @main
5
6
  struct __APP_NAME_PASCAL__App: App {
@@ -7,9 +8,17 @@ struct __APP_NAME_PASCAL__App: App {
7
8
 
8
9
  var body: some Scene {
9
10
  Window("__APP_NAME__", id: "main") {
10
- RootView()
11
- .environmentObject(session)
12
- .frame(minWidth: 480, minHeight: 600)
11
+ Group {
12
+ if let engine = session.engine {
13
+ RootView(engine: engine)
14
+ .environmentObject(session)
15
+ .frame(minWidth: 480, minHeight: 600)
16
+ } else {
17
+ ProgressView()
18
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
19
+ }
20
+ }
21
+ .task { await session.bootIfNeeded() }
13
22
  }
14
23
  .windowResizability(.contentMinSize)
15
24
  }
@@ -17,8 +26,9 @@ struct __APP_NAME_PASCAL__App: App {
17
26
 
18
27
  @MainActor
19
28
  final class AppSession: ObservableObject {
20
- let pylon: PylonClient
21
- @Published var me: Profile?
29
+ let client: PylonClient
30
+ @Published private(set) var engine: SyncEngine?
31
+ @Published var myProfileId: String?
22
32
 
23
33
  init() {
24
34
  let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
@@ -26,6 +36,24 @@ final class AppSession: ObservableObject {
26
36
  guard let url = URL(string: baseURLString) else {
27
37
  fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
28
38
  }
29
- self.pylon = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
39
+ self.client = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
40
+ self.myProfileId = UserDefaults.standard.string(forKey: "myProfileId")
41
+ }
42
+
43
+ func bootIfNeeded() async {
44
+ guard engine == nil else { return }
45
+ let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
46
+ ?? "http://localhost:4321"
47
+ guard let url = URL(string: baseURLString) else { return }
48
+ let config = SyncEngineConfig(baseURL: url, appName: "__APP_NAME_SNAKE__")
49
+ let engine = await SyncEngine(config: config, client: client)
50
+ await engine.start()
51
+ self.engine = engine
52
+ }
53
+
54
+ func setMyProfileId(_ id: String?) {
55
+ myProfileId = id
56
+ if let id { UserDefaults.standard.set(id, forKey: "myProfileId") }
57
+ else { UserDefaults.standard.removeObject(forKey: "myProfileId") }
30
58
  }
31
59
  }
@@ -30,5 +30,7 @@ targets:
30
30
  dependencies:
31
31
  - package: pylon
32
32
  product: PylonClient
33
+ - package: pylon
34
+ product: PylonSync
33
35
  - package: pylon
34
36
  product: PylonSwiftUI
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useRef, useState, useTransition } from "react";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
4
5
  import { Button, Input } from "@__APP_NAME_KEBAB__/ui";
5
6
 
6
7
  type Room = {
@@ -21,47 +22,29 @@ type Message = {
21
22
 
22
23
  const NAME_KEY = "__APP_NAME_SNAKE___author_name";
23
24
 
24
- export function ChatRoom({
25
- initialRooms,
26
- initialActiveRoom,
27
- initialMessages,
28
- }: {
29
- initialRooms: Room[];
30
- initialActiveRoom: Room | null;
31
- initialMessages: Message[];
32
- }) {
33
- const [rooms, setRooms] = useState(initialRooms);
34
- const [active, setActive] = useState(initialActiveRoom);
35
- const [messages, setMessages] = useState(initialMessages);
25
+ export function ChatRoom() {
26
+ // Live subscriptions. The sync engine pushes diffs over WebSocket
27
+ // — every Room insert + every Message append from any other client
28
+ // re-renders these components without a fetch / poll / refresh.
29
+ const { data: rooms = [] } = db.useQuery<Room>("Room", {
30
+ orderBy: { createdAt: "asc" },
31
+ });
32
+ const [activeId, setActiveId] = useState<string | null>(null);
33
+ useEffect(() => {
34
+ if (!activeId && rooms.length > 0) setActiveId(rooms[0].id);
35
+ }, [rooms, activeId]);
36
+
37
+ const { data: messages = [] } = db.useQuery<Message>("Message", {
38
+ where: activeId ? { roomId: activeId } : undefined,
39
+ orderBy: { createdAt: "asc" },
40
+ limit: 200,
41
+ });
42
+
36
43
  const [body, setBody] = useState("");
37
44
  const [authorName, setAuthorName] = useState("anonymous");
38
- const [pending, startTransition] = useTransition();
39
- const [pollIdx, setPollIdx] = useState(0);
45
+ const [sending, setSending] = useState(false);
40
46
  const scrollerRef = useRef<HTMLDivElement>(null);
41
47
 
42
- // Poll the active room every 1.5s. The framework supports a
43
- // WebSocket subscription path (db.useQuery) — we use polling here
44
- // so the scaffold has zero front-end SDK setup. Swap in
45
- // db.useQuery("Message", { roomId }) when you wire up
46
- // `@pylonsync/react`'s init() in your layout.
47
- useEffect(() => {
48
- if (!active) return;
49
- const t = setInterval(() => setPollIdx((n) => n + 1), 1500);
50
- return () => clearInterval(t);
51
- }, [active]);
52
-
53
- useEffect(() => {
54
- if (!active) return;
55
- void (async () => {
56
- const res = await fetch("/api/fn/roomMessages", {
57
- method: "POST",
58
- headers: { "Content-Type": "application/json" },
59
- body: JSON.stringify({ roomId: active.id }),
60
- });
61
- if (res.ok) setMessages(await res.json());
62
- })();
63
- }, [active, pollIdx]);
64
-
65
48
  useEffect(() => {
66
49
  const stored =
67
50
  typeof window !== "undefined"
@@ -71,37 +54,38 @@ export function ChatRoom({
71
54
  }, []);
72
55
 
73
56
  useEffect(() => {
74
- // Auto-scroll to bottom on new messages.
57
+ // Auto-scroll to bottom when message count changes.
75
58
  scrollerRef.current?.scrollTo({
76
59
  top: scrollerRef.current.scrollHeight,
77
60
  behavior: "smooth",
78
61
  });
79
- }, [messages]);
62
+ }, [messages.length, activeId]);
80
63
 
81
64
  function persistName(next: string) {
82
65
  setAuthorName(next);
83
66
  window.localStorage.setItem(NAME_KEY, next);
84
67
  }
85
68
 
69
+ const active = rooms.find((r) => r.id === activeId) ?? null;
70
+
86
71
  async function send() {
87
72
  const trimmed = body.trim();
88
73
  if (!trimmed || !active) return;
89
74
  setBody("");
90
- startTransition(async () => {
91
- const res = await fetch("/api/fn/sendMessage", {
92
- method: "POST",
93
- headers: { "Content-Type": "application/json" },
94
- body: JSON.stringify({
95
- roomId: active.id,
96
- body: trimmed,
97
- authorName,
98
- }),
75
+ setSending(true);
76
+ try {
77
+ // Server validates body length / auth, runs ctx.db.insert,
78
+ // the resulting change_event hits every subscriber's local
79
+ // store. We don't append to local state ourselves — the
80
+ // useQuery above re-renders when the new row lands.
81
+ await callFn("sendMessage", {
82
+ roomId: active.id,
83
+ body: trimmed,
84
+ authorName,
99
85
  });
100
- if (res.ok) {
101
- const msg = (await res.json()) as Message;
102
- setMessages((prev) => [...prev, msg]);
103
- }
104
- });
86
+ } finally {
87
+ setSending(false);
88
+ }
105
89
  }
106
90
 
107
91
  async function createRoom() {
@@ -111,18 +95,12 @@ export function ChatRoom({
111
95
  .toLowerCase()
112
96
  .replace(/[^a-z0-9]+/g, "-")
113
97
  .replace(/^-|-$/g, "");
114
- startTransition(async () => {
115
- const res = await fetch("/api/fn/createRoom", {
116
- method: "POST",
117
- headers: { "Content-Type": "application/json" },
118
- body: JSON.stringify({ slug, name }),
119
- });
120
- if (res.ok) {
121
- const room = (await res.json()) as Room;
122
- setRooms((prev) => [...prev, room]);
123
- setActive(room);
124
- }
125
- });
98
+ try {
99
+ const room = (await callFn("createRoom", { slug, name })) as Room;
100
+ setActiveId(room.id);
101
+ } catch (e) {
102
+ window.alert(`Create failed: ${String(e)}`);
103
+ }
126
104
  }
127
105
 
128
106
  return (
@@ -149,9 +127,9 @@ export function ChatRoom({
149
127
  {rooms.map((r) => (
150
128
  <li key={r.id}>
151
129
  <button
152
- onClick={() => setActive(r)}
130
+ onClick={() => setActiveId(r.id)}
153
131
  className={`w-full text-left px-4 py-2.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-900 ${
154
- r.id === active?.id
132
+ r.id === activeId
155
133
  ? "bg-neutral-100 dark:bg-neutral-800 font-medium"
156
134
  : ""
157
135
  }`}
@@ -181,7 +159,10 @@ export function ChatRoom({
181
159
  </p>
182
160
  </header>
183
161
 
184
- <div ref={scrollerRef} className="flex-1 overflow-auto px-6 py-4 space-y-3">
162
+ <div
163
+ ref={scrollerRef}
164
+ className="flex-1 overflow-auto px-6 py-4 space-y-3"
165
+ >
185
166
  {messages.length === 0 ? (
186
167
  <p className="text-sm text-neutral-500 text-center py-12">
187
168
  No messages yet. Say hi.
@@ -219,13 +200,13 @@ export function ChatRoom({
219
200
  value={body}
220
201
  onChange={(e) => setBody(e.target.value)}
221
202
  placeholder={`Message ${active.name}…`}
222
- disabled={pending}
203
+ disabled={sending}
223
204
  className="flex-1"
224
205
  />
225
206
  <Button
226
207
  type="submit"
227
208
  variant="primary"
228
- disabled={pending || !body.trim()}
209
+ disabled={sending || !body.trim()}
229
210
  >
230
211
  Send
231
212
  </Button>
@@ -1,9 +1,10 @@
1
1
  import type { Metadata } from "next";
2
2
  import "./globals.css";
3
+ import { Providers } from "./providers";
3
4
 
4
5
  export const metadata: Metadata = {
5
6
  title: "__APP_NAME__",
6
- description: "Multi-tenant SaaS scaffold powered by Pylon",
7
+ description: "Realtime chat powered by Pylon's sync engine",
7
8
  };
8
9
 
9
10
  export default function RootLayout({
@@ -14,7 +15,7 @@ export default function RootLayout({
14
15
  return (
15
16
  <html lang="en">
16
17
  <body className="antialiased min-h-screen bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100">
17
- {children}
18
+ <Providers>{children}</Providers>
18
19
  </body>
19
20
  </html>
20
21
  );
@@ -1,51 +1,12 @@
1
- import { pylon } from "@/lib/pylon";
2
1
  import { ChatRoom } from "./components/ChatRoom";
3
2
 
4
- export const dynamic = "force-dynamic";
5
-
6
- type Room = {
7
- id: string;
8
- slug: string;
9
- name: string;
10
- createdAt: string;
11
- };
12
-
13
- type Message = {
14
- id: string;
15
- roomId: string;
16
- authorId: string;
17
- authorName: string;
18
- body: string;
19
- createdAt: string;
20
- };
21
-
22
- export default async function HomePage() {
23
- const rooms = await pylon
24
- .json<Room[]>("/api/fn/listRooms", {
25
- method: "POST",
26
- body: "{}",
27
- headers: { "Content-Type": "application/json" },
28
- })
29
- .catch(() => [] as Room[]);
30
-
31
- const initialRoom = rooms[0] ?? null;
32
- const initialMessages = initialRoom
33
- ? await pylon
34
- .json<Message[]>("/api/fn/roomMessages", {
35
- method: "POST",
36
- body: JSON.stringify({ roomId: initialRoom.id }),
37
- headers: { "Content-Type": "application/json" },
38
- })
39
- .catch(() => [] as Message[])
40
- : [];
41
-
3
+ // All data flows through `db.useQuery` in the client component below —
4
+ // no server-side initial fetch needed. The sync engine handles initial
5
+ // hydration + every subsequent change over the same WebSocket.
6
+ export default function HomePage() {
42
7
  return (
43
8
  <main className="h-screen flex">
44
- <ChatRoom
45
- initialRooms={rooms}
46
- initialActiveRoom={initialRoom}
47
- initialMessages={initialMessages}
48
- />
9
+ <ChatRoom />
49
10
  </main>
50
11
  );
51
12
  }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { init } from "@pylonsync/react";
5
+
6
+ /**
7
+ * Boots the Pylon sync engine on first mount. Every `db.useQuery`
8
+ * downstream renders against the same engine instance, so a single
9
+ * top-level Providers wrapper is enough — no need to thread a client
10
+ * through context.
11
+ *
12
+ * `baseUrl` is the same-origin Next path because next.config.ts
13
+ * proxies /api/* to the Pylon binary on PYLON_API_URL. Browser →
14
+ * same-origin → Next server → Pylon: no CORS, no extra DNS, the
15
+ * cookie travels naturally.
16
+ */
17
+ export function Providers({ children }: { children: React.ReactNode }) {
18
+ const [ready, setReady] = useState(false);
19
+ useEffect(() => {
20
+ init({ baseUrl: "", appName: "__APP_NAME_SNAKE__" });
21
+ setReady(true);
22
+ }, []);
23
+ if (!ready) return null;
24
+ return <>{children}</>;
25
+ }