@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.
- package/dist/build-cli.d.ts +1 -0
- package/dist/build-compat.d.ts +81 -0
- package/dist/index.d.ts +1 -1
- package/dist/production-build.d.ts +47 -0
- package/dist/server-bundle.d.ts +31 -0
- package/dist/ssr-client-bundler.d.ts +31 -3
- package/dist/ssr-fonts.d.ts +1 -1
- package/dist/ssr-runtime.d.ts +13 -1
- package/dist/stdout-fence.d.ts +18 -0
- package/dist/types.d.ts +42 -0
- package/package.json +8 -4
- package/src/build-cli.ts +36 -0
- package/src/build-compat.test.ts +173 -0
- package/src/build-compat.ts +430 -0
- package/src/index.ts +1 -0
- package/src/production-build.test.ts +269 -0
- package/src/production-build.ts +577 -0
- package/src/runtime-shard-ticket.test.ts +87 -0
- package/src/runtime.ts +64 -82
- package/src/server-bundle.test.ts +89 -0
- package/src/server-bundle.ts +63 -0
- package/src/ssr-client-bundler.ts +125 -13
- package/src/ssr-fonts.ts +1 -1
- package/src/ssr-og-runtime.ts +17 -5
- package/src/ssr-runtime.ts +112 -47
- package/src/stdout-fence.ts +69 -0
- package/src/types.ts +42 -0
package/src/ssr-og-runtime.ts
CHANGED
|
@@ -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
|
|
72
|
-
|
|
73
|
-
const
|
|
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:
|
|
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:
|
|
107
|
+
data: bundled
|
|
108
|
+
? readFileSync(bundled.interSemiBold)
|
|
109
|
+
: read("../assets/fonts/Inter-SemiBold.ttf"),
|
|
98
110
|
weight: 600,
|
|
99
111
|
style: "normal",
|
|
100
112
|
},
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
833
|
-
|
|
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
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
2262
|
-
|
|
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
|
-
|
|
3206
|
-
|
|
3207
|
-
reactDomServerImport =
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
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
|
-
|
|
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,
|