@pylonsync/functions 0.11.6 → 0.12.0

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.
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { readFileSync } from "node:fs";
27
27
  import { fileURLToPath } from "node:url";
28
+ import { serverBundle } from "./server-bundle";
28
29
 
29
30
  // Satori's font descriptor.
30
31
  export interface OgFont {
@@ -68,9 +69,15 @@ function loadResvg() {
68
69
  if (!resvgReady) {
69
70
  resvgReady = (async () => {
70
71
  const mod = await import("@resvg/resvg-wasm");
71
- // Locate the wasm binary next to the package entry and init once.
72
- const entry = fileURLToPath(import.meta.resolve("@resvg/resvg-wasm"));
73
- const wasmPath = entry.replace(/index\.[^/]+$/, "index_bg.wasm");
72
+ // Locate the wasm binary next to the package entry (or in the
73
+ // production server bundle) and init once.
74
+ const bundled = serverBundle()?.ogAssets.resvgWasm;
75
+ const wasmPath =
76
+ bundled ??
77
+ fileURLToPath(import.meta.resolve("@resvg/resvg-wasm")).replace(
78
+ /index\.[^/]+$/,
79
+ "index_bg.wasm",
80
+ );
74
81
  await mod.initWasm(readFileSync(wasmPath));
75
82
  return mod;
76
83
  })();
@@ -83,18 +90,23 @@ function loadResvg() {
83
90
  let defaultFonts: OgFont[] | null = null;
84
91
  function loadDefaultFonts(): OgFont[] {
85
92
  if (defaultFonts) return defaultFonts;
93
+ const bundled = serverBundle()?.ogAssets;
86
94
  const read = (rel: string) =>
87
95
  readFileSync(fileURLToPath(new URL(rel, import.meta.url)));
88
96
  defaultFonts = [
89
97
  {
90
98
  name: "Inter",
91
- data: read("../assets/fonts/Inter-Regular.ttf"),
99
+ data: bundled
100
+ ? readFileSync(bundled.interRegular)
101
+ : read("../assets/fonts/Inter-Regular.ttf"),
92
102
  weight: 400,
93
103
  style: "normal",
94
104
  },
95
105
  {
96
106
  name: "Inter",
97
- data: read("../assets/fonts/Inter-SemiBold.ttf"),
107
+ data: bundled
108
+ ? readFileSync(bundled.interSemiBold)
109
+ : read("../assets/fonts/Inter-SemiBold.ttf"),
98
110
  weight: 600,
99
111
  style: "normal",
100
112
  },
@@ -7,6 +7,8 @@
7
7
  // the dispatch arm) so projects without SSR routes pay nothing — no
8
8
  // react-dom dependency requirement, no startup cost.
9
9
 
10
+ import { moduleKey, serverBundle } from "./server-bundle";
11
+
10
12
  // Bun runtime global. This module runs under Bun but is type-checked by
11
13
  // consuming apps under node/DOM (no `Bun` global) — declare the surface used.
12
14
  declare const Bun: {
@@ -716,8 +718,19 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
716
718
 
717
719
  const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
718
720
 
719
- /** Import a project-relative module, trying each common extension. */
721
+ /**
722
+ * Import a project-relative module, trying each common extension. A
723
+ * production server bundle serves it from the bundle's module registry.
724
+ */
720
725
  export async function importModule(cwd: string, relPath: string): Promise<any> {
726
+ const bundle = serverBundle();
727
+ if (bundle) {
728
+ const load = bundle.modules[moduleKey(relPath)];
729
+ if (load === undefined) {
730
+ throw new Error(`module "${relPath}" is not in the server bundle`);
731
+ }
732
+ return load();
733
+ }
721
734
  const base = `${cwd}/${relPath}`;
722
735
  let lastErr: unknown = null;
723
736
  for (const ext of MODULE_EXTS) {
@@ -730,6 +743,58 @@ export async function importModule(cwd: string, relPath: string): Promise<any> {
730
743
  throw lastErr ?? new Error(`could not import module "${relPath}"`);
731
744
  }
732
745
 
746
+ /**
747
+ * True when the project has a module at `<dir>/<name>` with any code
748
+ * extension. `dir` is project-relative and "/"-separated. A production server
749
+ * bundle answers from its module registry; source mode checks the disk.
750
+ */
751
+ export function moduleExistsIn(
752
+ fs: any,
753
+ path: any,
754
+ cwd: string,
755
+ dir: string,
756
+ name: string,
757
+ ): boolean {
758
+ const bundle = serverBundle();
759
+ if (bundle) return moduleKey(`${dir}/${name}`) in bundle.modules;
760
+ return MODULE_EXTS.some((ext) =>
761
+ fs.existsSync(path.join(cwd, dir, `${name}${ext}`)),
762
+ );
763
+ }
764
+
765
+ /**
766
+ * The route-group directories (`(name)`) directly under the project-relative
767
+ * `dir`, sorted by name. A production server bundle derives them from its
768
+ * module registry keys; source mode reads the directory.
769
+ */
770
+ function routeGroupDirsIn(fs: any, path: any, cwd: string, dir: string): string[] {
771
+ const isGroup = (n: string) => n.startsWith("(") && n.endsWith(")");
772
+ const bundle = serverBundle();
773
+ if (bundle) {
774
+ const prefix = `${dir}/`;
775
+ const found = new Set<string>();
776
+ for (const key of Object.keys(bundle.modules)) {
777
+ if (!key.startsWith(prefix)) continue;
778
+ const child = key.slice(prefix.length).split("/")[0];
779
+ // A key with no "/" after the prefix is a module file, not a directory.
780
+ if (key.slice(prefix.length).includes("/") && isGroup(child)) {
781
+ found.add(child);
782
+ }
783
+ }
784
+ return [...found].sort();
785
+ }
786
+ let entries: any[];
787
+ try {
788
+ entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true });
789
+ } catch {
790
+ return [];
791
+ }
792
+ return entries
793
+ .filter((e: any) => e.isDirectory() && isGroup(e.name))
794
+ .map((e: any) => e.name as string)
795
+ .sort();
796
+ }
797
+
733
798
  /**
734
799
  * Wrap a leaf element in its layout chain (leaf → root). Resolves ALL
735
800
  * layouts first so a missing one fails before any chunk is emitted. Reused
@@ -829,25 +894,10 @@ function boundaryInDirOrGroups(
829
894
  dir: string,
830
895
  fileName: string,
831
896
  ): string | null {
832
- for (const ext of MODULE_EXTS) {
833
- if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
834
- return `${dir}/${fileName}`;
835
- }
836
- }
837
- let entries: any[];
838
- try {
839
- entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true });
840
- } catch {
841
- return null;
897
+ if (moduleExistsIn(fs, path, cwd, dir, fileName)) {
898
+ return `${dir}/${fileName}`;
842
899
  }
843
- const groups = entries
844
- .filter(
845
- (e: any) =>
846
- e.isDirectory() && e.name.startsWith("(") && e.name.endsWith(")"),
847
- )
848
- .map((e: any) => e.name as string)
849
- .sort();
850
- for (const g of groups) {
900
+ for (const g of routeGroupDirsIn(fs, path, cwd, dir)) {
851
901
  const hit = boundaryInDirOrGroups(fs, path, cwd, `${dir}/${g}`, fileName);
852
902
  if (hit) return hit;
853
903
  }
@@ -892,8 +942,6 @@ function findColocatedImage(
892
942
  return null;
893
943
  }
894
944
 
895
- const OG_IMAGE_CODE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
896
-
897
945
  /**
898
946
  * Walk up from a page's directory to the nearest colocated dynamic OG
899
947
  * module (`opengraph-image.{tsx,ts,jsx,js}`) and return the CONCRETE
@@ -938,14 +986,12 @@ function findColocatedOgImageRoute(
938
986
  return below.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).length;
939
987
  };
940
988
  while (dir && dir !== "." && dir !== "/") {
941
- for (const ext of OG_IMAGE_CODE_EXTS) {
942
- if (fs.existsSync(path.join(cwd, dir, `opengraph-image${ext}`))) {
943
- const prefix =
944
- dir === pageDir && !isBoundary
945
- ? urlSegs
946
- : urlSegs.slice(0, nonGroupDepth(dir));
947
- return "/" + [...prefix, "opengraph-image"].join("/");
948
- }
989
+ if (moduleExistsIn(fs, path, cwd, dir, "opengraph-image")) {
990
+ const prefix =
991
+ dir === pageDir && !isBoundary
992
+ ? urlSegs
993
+ : urlSegs.slice(0, nonGroupDepth(dir));
994
+ return "/" + [...prefix, "opengraph-image"].join("/");
949
995
  }
950
996
  const slash = dir.lastIndexOf("/");
951
997
  dir = slash >= 0 ? dir.slice(0, slash) : "";
@@ -2098,6 +2144,9 @@ export function buildHydrationTail(args: {
2098
2144
  manifestRoute: { file: string; imports: string[]; css: string[] } | null;
2099
2145
  publicPrefix: string;
2100
2146
  manifestErr: string | null;
2147
+ /** core-js polyfill bundle (relative to `publicPrefix`), from the bundle
2148
+ * manifest. Loaded before the route entry. */
2149
+ polyfills?: string;
2101
2150
  kind?: "error" | "not-found";
2102
2151
  errorForClient?: { message: string; digest?: string };
2103
2152
  // PPR Phase 0 (auth-bucketed caching): when set, this render is being stored
@@ -2181,6 +2230,11 @@ export function buildHydrationTail(args: {
2181
2230
  // reusable (no double fetch) and surfaces load errors. No-op for the
2182
2231
  // same-origin `/_pylon/build/` default.
2183
2232
  const co = /^https?:\/\//i.test(args.publicPrefix) ? " crossorigin" : "";
2233
+ // Module scripts run in document order, so the polyfills are in place
2234
+ // before the entry (and the shared chunk it imports) runs.
2235
+ if (args.polyfills) {
2236
+ tail += `<script type="module"${co} src="${args.publicPrefix}${args.polyfills}"></script>`;
2237
+ }
2184
2238
  tail += `<script type="module"${co} src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
2185
2239
  } else {
2186
2240
  // Executable script → the serialized message MUST be script-escaped
@@ -2258,11 +2312,8 @@ function resolveLayoutChain(componentRelPath: string, cwd: string): string[] {
2258
2312
  let acc = "";
2259
2313
  for (const part of parts) {
2260
2314
  acc = acc ? `${acc}/${part}` : part;
2261
- for (const ext of MODULE_EXTS) {
2262
- if (fs.existsSync(path.join(cwd, acc, `layout${ext}`))) {
2263
- layouts.push(`${acc}/layout`);
2264
- break;
2265
- }
2315
+ if (moduleExistsIn(fs, path, cwd, acc, "layout")) {
2316
+ layouts.push(`${acc}/layout`);
2266
2317
  }
2267
2318
  }
2268
2319
  return layouts;
@@ -2321,6 +2372,7 @@ async function renderBoundaryToClient(
2321
2372
  | { file: string; imports: string[]; css: string[] }
2322
2373
  | null = null;
2323
2374
  let publicPrefix = "/_pylon/build/";
2375
+ let polyfills: string | undefined;
2324
2376
  let headBlob = "";
2325
2377
  if (tail) {
2326
2378
  try {
@@ -2328,6 +2380,7 @@ async function renderBoundaryToClient(
2328
2380
  const manifest = await getManifest();
2329
2381
  publicPrefix = manifest.public_prefix || publicPrefix;
2330
2382
  manifestRoute = manifest.routes[tail.component] ?? null;
2383
+ polyfills = manifest.polyfills;
2331
2384
  } catch {
2332
2385
  manifestRoute = null;
2333
2386
  }
@@ -2374,6 +2427,7 @@ async function renderBoundaryToClient(
2374
2427
  manifestRoute,
2375
2428
  publicPrefix,
2376
2429
  manifestErr: null,
2430
+ polyfills,
2377
2431
  kind: tail.kind,
2378
2432
  errorForClient: tail.errorForClient,
2379
2433
  });
@@ -3201,22 +3255,30 @@ export async function handleRenderRoute(
3201
3255
  // `react-dom/server` (which is Node-stream-style). Try browser
3202
3256
  // first, fall back to the default entry for environments that
3203
3257
  // re-route it (Next runs a custom dist).
3258
+ //
3259
+ // A production server bundle carries the app's React, bundled with the
3260
+ // page modules, so there is nothing to resolve.
3261
+ const bundle = serverBundle();
3204
3262
  let reactDomServerImport: any;
3205
- try {
3206
- // @ts-ignore — user-dep, resolved at runtime
3207
- reactDomServerImport = await import(
3208
- /* @vite-ignore */ resolveFromUser("react-dom/server.browser")
3209
- );
3210
- } catch {
3263
+ let reactImport: any;
3264
+ if (bundle) {
3265
+ reactDomServerImport = bundle.reactDomServer;
3266
+ reactImport = bundle.react;
3267
+ } else {
3268
+ try {
3269
+ // @ts-ignore — user-dep, resolved at runtime
3270
+ reactDomServerImport = await import(
3271
+ /* @vite-ignore */ resolveFromUser("react-dom/server.browser")
3272
+ );
3273
+ } catch {
3274
+ // @ts-ignore — user-dep, resolved at runtime
3275
+ reactDomServerImport = await import(
3276
+ /* @vite-ignore */ resolveFromUser("react-dom/server")
3277
+ );
3278
+ }
3211
3279
  // @ts-ignore — user-dep, resolved at runtime
3212
- reactDomServerImport = await import(
3213
- /* @vite-ignore */ resolveFromUser("react-dom/server")
3214
- );
3280
+ reactImport = await import(/* @vite-ignore */ resolveFromUser("react"));
3215
3281
  }
3216
- // @ts-ignore — user-dep, resolved at runtime
3217
- const reactImport = await import(
3218
- /* @vite-ignore */ resolveFromUser("react")
3219
- );
3220
3282
  React = reactImport.default ?? reactImport;
3221
3283
  renderToReadableStream =
3222
3284
  reactDomServerImport.renderToReadableStream ??
@@ -3700,11 +3762,13 @@ export async function handleRenderRoute(
3700
3762
  | null = null;
3701
3763
  let preloadManifestErr: string | null = null;
3702
3764
  let preloadPublicPrefix = "/_pylon/build/";
3765
+ let preloadPolyfills: string | undefined;
3703
3766
  try {
3704
3767
  const { getManifest } = await import("./ssr-client-bundler");
3705
3768
  const manifest = await getManifest();
3706
3769
  preloadPublicPrefix = manifest.public_prefix || preloadPublicPrefix;
3707
3770
  preloadManifestRoute = manifest.routes[msg.component] ?? null;
3771
+ preloadPolyfills = manifest.polyfills;
3708
3772
  if (!preloadManifestRoute) {
3709
3773
  preloadManifestErr = `manifest has no entry for "${msg.component}"`;
3710
3774
  }
@@ -3866,6 +3930,7 @@ export async function handleRenderRoute(
3866
3930
  manifestRoute: preloadManifestRoute,
3867
3931
  publicPrefix: preloadPublicPrefix,
3868
3932
  manifestErr: preloadManifestErr,
3933
+ polyfills: preloadPolyfills,
3869
3934
  kind: isBoundaryComponent
3870
3935
  ? /(^|\/)error$/.test(msg.component)
3871
3936
  ? "error"
@@ -0,0 +1,69 @@
1
+ // Keeps stdout for the host protocol. See `fenceStdout`.
2
+
3
+ // Bun global used here. Declared locally so apps that type-check this source
4
+ // without bun-types still pass `tsc` (same pattern as runtime.ts).
5
+ declare const Bun: {
6
+ write(dest: unknown, data: string): unknown;
7
+ stderr: unknown;
8
+ };
9
+
10
+ /**
11
+ * Redirect console.* from user code to stderr so handlers can't accidentally
12
+ * emit a line that looks like a protocol frame and confuse the Rust reader.
13
+ *
14
+ * Before this guard, a handler calling `console.log('{"type":"return",...}')`
15
+ * — either intentionally or by logging an object shaped that way — would be
16
+ * parsed by the host as a real protocol message. Moving all console output
17
+ * to stderr keeps stdout reserved for NDJSON protocol frames only.
18
+ *
19
+ * The original console methods are saved on the console object as
20
+ * `__stdoutLog` etc. in case the runtime itself needs to write diagnostics
21
+ * to stdout for some reason (it currently doesn't).
22
+ *
23
+ * Safe to call more than once. A production server bundle calls it from its
24
+ * entry, before the app modules load, and the runtime's `main()` calls it
25
+ * again.
26
+ */
27
+ export function fenceStdout(): void {
28
+ const c = globalThis.console as unknown as Record<string, unknown>;
29
+ if (c.__pylonFenced) return;
30
+ c.__pylonFenced = true;
31
+ const toStderr = (prefix: string) => (...args: unknown[]) => {
32
+ const line = args
33
+ .map((a) => {
34
+ if (typeof a === "string") return a;
35
+ // Error: JSON.stringify yields `{}` because message/stack are
36
+ // non-enumerable. That made `console.error("x:", err)` log as `x: {}`,
37
+ // hiding the real failure from operators. Unwrap by hand.
38
+ if (a instanceof Error) {
39
+ const parts = [a.stack || `${a.name}: ${a.message}`];
40
+ const code = (a as { code?: unknown }).code;
41
+ if (code !== undefined) parts.push(`code=${String(code)}`);
42
+ const cause = (a as { cause?: unknown }).cause;
43
+ if (cause !== undefined) {
44
+ try {
45
+ parts.push(`cause=${cause instanceof Error ? cause.stack || cause.message : JSON.stringify(cause)}`);
46
+ } catch {
47
+ parts.push(`cause=${String(cause)}`);
48
+ }
49
+ }
50
+ return parts.join(" ");
51
+ }
52
+ try {
53
+ return JSON.stringify(a);
54
+ } catch {
55
+ return String(a);
56
+ }
57
+ })
58
+ .join(" ");
59
+ Bun.write(Bun.stderr, `${prefix}${line}\n`);
60
+ };
61
+ // Intentional: we want console.* for user handlers to go to stderr.
62
+ // Overwrite the globals before any user code is loaded.
63
+ c.__stdoutLog = c.log;
64
+ c.log = toStderr("");
65
+ c.info = toStderr("");
66
+ c.warn = toStderr("[warn] ");
67
+ c.error = toStderr("[error] ");
68
+ c.debug = toStderr("[debug] ");
69
+ }
package/src/types.ts CHANGED
@@ -827,6 +827,42 @@ export interface Files {
827
827
  signedUrl(fileId: string, opts?: { ttlSecs?: number }): Promise<string>;
828
828
  }
829
829
 
830
+ /**
831
+ * Shard tickets: short-lived, signed permission to join one realtime shard,
832
+ * with claims the shard's authorization hooks read without a database
833
+ * call. Check what the user may do here, then mint:
834
+ *
835
+ * ```ts
836
+ * export default mutation({
837
+ * args: { characterId: v.id("Character") },
838
+ * async handler(ctx, args) {
839
+ * const c = await ctx.db.get("Character", args.characterId);
840
+ * if (c?.ownerId !== ctx.auth.userId) throw ctx.error("FORBIDDEN", "not your character");
841
+ * const shard = `zone-${c.zone}`;
842
+ * const ticket = await ctx.shards.ticket(shard, {
843
+ * subscriberId: c.id,
844
+ * claims: { character: c.id, realm: c.realm },
845
+ * });
846
+ * return { shard, ticket };
847
+ * },
848
+ * });
849
+ * ```
850
+ *
851
+ * The client passes the ticket when it connects (`connectShard(shard, {
852
+ * ticket })`). The shard rejects a ticket for another shard, another
853
+ * subscriber id, or past its expiry.
854
+ */
855
+ export interface Shards {
856
+ /**
857
+ * Mint a ticket for `shardId`. `subscriberId` defaults to the calling
858
+ * user's id; `ttlSecs` defaults to 60 and is capped at 3600 by the host.
859
+ */
860
+ ticket(
861
+ shardId: string,
862
+ opts?: { subscriberId?: string; claims?: Record<string, unknown>; ttlSecs?: number },
863
+ ): Promise<string>;
864
+ }
865
+
830
866
  /** Context for query handlers (read-only).
831
867
  *
832
868
  * NOTE: `ctx.llm` is NOT exposed here. Queries are reactive: a
@@ -852,6 +888,8 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
852
888
  requireMember: RequireMember;
853
889
  /** Signed file-download URLs — see {@link Files}. */
854
890
  files: Files;
891
+ /** Shard tickets — see {@link Shards}. */
892
+ shards: Shards;
855
893
  /**
856
894
  * Fires when the host cancels this call (idle timeout exceeded).
857
895
  * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
@@ -880,6 +918,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
880
918
  workflows: Workflows;
881
919
  /** Signed file-download URLs — see {@link Files}. */
882
920
  files: Files;
921
+ /** Shard tickets — see {@link Shards}. */
922
+ shards: Shards;
883
923
  /** Create a typed error that triggers rollback. */
884
924
  error(code: string, message: string): Error;
885
925
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
@@ -1053,6 +1093,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1053
1093
  env: Record<string, string>;
1054
1094
  /** Signed file-download URLs — see {@link Files}. */
1055
1095
  files: Files;
1096
+ /** Shard tickets — see {@link Shards}. */
1097
+ shards: Shards;
1056
1098
  /** Run a registered query within its own read transaction. */
1057
1099
  runQuery<T = unknown>(
1058
1100
  fnName: string,