@pylonsync/functions 0.22.40 → 0.22.43
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/index.d.ts +1 -1
- package/dist/ssr-page-tree.d.ts +4 -0
- package/dist/types.d.ts +25 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/runtime.ts +38 -0
- package/src/ssr-client-bundler.test.ts +34 -0
- package/src/ssr-client-bundler.ts +49 -27
- package/src/ssr-hydration.test.ts +91 -0
- package/src/ssr-page-tree.ts +21 -0
- package/src/ssr-runtime.ts +13 -14
- package/src/types.ts +26 -0
package/dist/index.d.ts
CHANGED
|
@@ -27,4 +27,4 @@ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunne
|
|
|
27
27
|
export { resetDb, installTestIsolation } from "./testing";
|
|
28
28
|
export { slugifyName, availableSlug } from "./slugify";
|
|
29
29
|
export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
|
|
30
|
-
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, Agents, AgentRunOptions, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, Files, ActionFiles, StoreFileOptions, StoredFile, } from "./types";
|
|
30
|
+
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, Agents, AgentRunOptions, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, Files, ActionFiles, Crdt, StoreFileOptions, StoredFile, } from "./types";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
type CreateElement = (type: any, props: any, ...children: any[]) => any;
|
|
2
|
+
/** The page with its metadata slot. `meta` is null in the browser. */
|
|
3
|
+
export declare function withMetaSlot(createElement: CreateElement, Fragment: any, meta: unknown, page: unknown): any;
|
|
4
|
+
export {};
|
package/dist/types.d.ts
CHANGED
|
@@ -949,6 +949,29 @@ export type RequireMember = (orgId: string, opts?: RequireMemberOptions) => Prom
|
|
|
949
949
|
* });
|
|
950
950
|
* ```
|
|
951
951
|
*/
|
|
952
|
+
/**
|
|
953
|
+
* `ctx.crdt` (actions only): read and change a row's live CRDT document, the
|
|
954
|
+
* one editors bind to with `@pylonsync/loro`. Bytes are Loro's own formats.
|
|
955
|
+
* `@pylonsync/loro/server` wraps this in a `LoroDoc` (`openLiveDoc`).
|
|
956
|
+
*
|
|
957
|
+
* Each `apply` is its own write: it merges, then reaches everyone editing at
|
|
958
|
+
* once, so it cannot roll back with a mutation (queries and mutations get
|
|
959
|
+
* CRDT_ACTIONS_ONLY). Policies follow the `ctx.db` rule: they apply under
|
|
960
|
+
* strict function policies (`PYLON_STRICT_FN_POLICIES=1`), except through
|
|
961
|
+
* `ctx.crdt.unsafe`. Without strict policies, check access yourself.
|
|
962
|
+
*/
|
|
963
|
+
export interface Crdt {
|
|
964
|
+
/** The row's whole document, or null when the row does not exist (or, under
|
|
965
|
+
* strict policies, the caller may not read it). */
|
|
966
|
+
snapshot(entity: string, id: string): Promise<Uint8Array | null>;
|
|
967
|
+
/** The changes since `version` (a Loro version vector, `doc.oplogVersion().encode()`),
|
|
968
|
+
* or null as for {@link Crdt.snapshot}. */
|
|
969
|
+
since(entity: string, id: string, version: Uint8Array): Promise<Uint8Array | null>;
|
|
970
|
+
/** Merge a Loro update into the row and broadcast it. */
|
|
971
|
+
apply(entity: string, id: string, update: Uint8Array): Promise<void>;
|
|
972
|
+
/** The same operations with server trust even under strict policies. */
|
|
973
|
+
unsafe: Omit<Crdt, "unsafe">;
|
|
974
|
+
}
|
|
952
975
|
export interface Files {
|
|
953
976
|
/**
|
|
954
977
|
* Mint a signed download path for a file id. `ttlSecs` defaults to 300
|
|
@@ -1422,6 +1445,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
1422
1445
|
env: Record<string, string>;
|
|
1423
1446
|
/** Signed file-download URLs — see {@link Files}. */
|
|
1424
1447
|
files: ActionFiles;
|
|
1448
|
+
/** A row's live CRDT document, as Loro bytes — see {@link Crdt}. */
|
|
1449
|
+
crdt: Crdt;
|
|
1425
1450
|
/** Shard tickets, reads, and start/stop — see {@link Shards}. */
|
|
1426
1451
|
shards: Shards;
|
|
1427
1452
|
/** Run a registered query within its own read transaction. */
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
EmailSender,
|
|
25
25
|
Files,
|
|
26
26
|
ActionFiles,
|
|
27
|
+
Crdt,
|
|
27
28
|
StoredFile,
|
|
28
29
|
Shards,
|
|
29
30
|
ShardInfo,
|
|
@@ -673,6 +674,36 @@ function buildActionFiles(callId: string): ActionFiles {
|
|
|
673
674
|
* FILES_STORE_MAX_BYTES; `PYLON_MAX_UPLOAD_BYTES` can lower the host's. */
|
|
674
675
|
const FILES_STORE_MAX_BYTES = 25 * 1024 * 1024;
|
|
675
676
|
|
|
677
|
+
/** `ctx.crdt` — see {@link Crdt}. */
|
|
678
|
+
function buildCrdt(callId: string): Crdt {
|
|
679
|
+
const ops = (unsafeOp: boolean): Omit<Crdt, "unsafe"> => {
|
|
680
|
+
const call = (entity: string, id: string, op: Record<string, unknown>) =>
|
|
681
|
+
rpcDb(callId, { type: "crdt_op", entity, id, unsafe_op: unsafeOp, ...op }) as Promise<
|
|
682
|
+
Record<string, string> | null
|
|
683
|
+
>;
|
|
684
|
+
return {
|
|
685
|
+
async snapshot(entity, id) {
|
|
686
|
+
const res = await call(entity, id, { op: "snapshot" });
|
|
687
|
+
return res ? fromBase64(res.snapshot) : null;
|
|
688
|
+
},
|
|
689
|
+
async since(entity, id, version) {
|
|
690
|
+
const res = await call(entity, id, { op: "since", version_base64: toBase64(version) });
|
|
691
|
+
return res ? fromBase64(res.update) : null;
|
|
692
|
+
},
|
|
693
|
+
async apply(entity, id, update) {
|
|
694
|
+
await call(entity, id, { op: "apply", update_base64: toBase64(update) });
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
};
|
|
698
|
+
return { ...ops(false), unsafe: ops(true) };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Bytes of a base64 string. */
|
|
702
|
+
function fromBase64(data: string): Uint8Array {
|
|
703
|
+
const buf = Buffer.from(data, "base64");
|
|
704
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
705
|
+
}
|
|
706
|
+
|
|
676
707
|
/** Base64 of bytes, or of a string's UTF-8 encoding. */
|
|
677
708
|
function toBase64(data: Uint8Array | ArrayBuffer | string): string {
|
|
678
709
|
const bytes =
|
|
@@ -1445,6 +1476,7 @@ function buildActionCtx(
|
|
|
1445
1476
|
return err;
|
|
1446
1477
|
},
|
|
1447
1478
|
files: buildActionFiles(callId),
|
|
1479
|
+
crdt: buildCrdt(callId),
|
|
1448
1480
|
shards: buildShards(callId),
|
|
1449
1481
|
// Actions have no ctx.db; read membership via the built-in internal query.
|
|
1450
1482
|
requireMember: makeRequireMember(auth.userId, (entity, filter) =>
|
|
@@ -1640,6 +1672,9 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1640
1672
|
// so a call from a query or mutation gets the host's
|
|
1641
1673
|
// FILES_WRITE_NOT_ALLOWED instead of a TypeError.
|
|
1642
1674
|
files: buildActionFiles(msg.call_id),
|
|
1675
|
+
// Not on the query or mutation type; present at runtime so a call
|
|
1676
|
+
// gets the host's CRDT_ACTIONS_ONLY instead of a TypeError.
|
|
1677
|
+
crdt: buildCrdt(msg.call_id),
|
|
1643
1678
|
shards: buildShards(msg.call_id),
|
|
1644
1679
|
};
|
|
1645
1680
|
break;
|
|
@@ -1669,6 +1704,9 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1669
1704
|
// so a call from a query or mutation gets the host's
|
|
1670
1705
|
// FILES_WRITE_NOT_ALLOWED instead of a TypeError.
|
|
1671
1706
|
files: buildActionFiles(msg.call_id),
|
|
1707
|
+
// Not on the query or mutation type; present at runtime so a call
|
|
1708
|
+
// gets the host's CRDT_ACTIONS_ONLY instead of a TypeError.
|
|
1709
|
+
crdt: buildCrdt(msg.call_id),
|
|
1672
1710
|
shards: buildShards(msg.call_id),
|
|
1673
1711
|
};
|
|
1674
1712
|
break;
|
|
@@ -395,6 +395,33 @@ export default function Doc() {
|
|
|
395
395
|
});
|
|
396
396
|
});
|
|
397
397
|
|
|
398
|
+
describe("assets a stylesheet references", () => {
|
|
399
|
+
// The asset plugin answers JavaScript imports with a JavaScript module; a
|
|
400
|
+
// stylesheet's url(...) must stay with Bun, or the build fails with
|
|
401
|
+
// "Cannot import a .js file into a CSS file".
|
|
402
|
+
test("a page importing CSS that uses url() still builds", async () => {
|
|
403
|
+
tempDir = makeFixture(
|
|
404
|
+
{
|
|
405
|
+
"page.tsx": `
|
|
406
|
+
import React from "react";
|
|
407
|
+
import "./look.css";
|
|
408
|
+
export default function Home() {
|
|
409
|
+
return <div className="hero">home</div>;
|
|
410
|
+
}
|
|
411
|
+
`,
|
|
412
|
+
},
|
|
413
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
414
|
+
);
|
|
415
|
+
fs.writeFileSync(path.join(tempDir, "app", "look.css"), ".hero { background: url(./bg.png); }\n");
|
|
416
|
+
fs.writeFileSync(path.join(tempDir, "app", "bg.png"), new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
417
|
+
originalCwd = process.cwd();
|
|
418
|
+
process.chdir(tempDir);
|
|
419
|
+
|
|
420
|
+
const { manifestPath } = await buildClientBundle();
|
|
421
|
+
expect(fs.existsSync(manifestPath)).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
398
425
|
describe("nearestBoundaryComponent (client boundary resolution)", () => {
|
|
399
426
|
const routes = new Set([
|
|
400
427
|
"web/app/not-found",
|
|
@@ -767,6 +794,13 @@ describe("loading.tsx is wired into the client build", () => {
|
|
|
767
794
|
// (see ssr-hydration.test.ts for why a missing boundary breaks hydration).
|
|
768
795
|
expect(runtime).toContain("buildTree(Page, Layouts, currentPageProps, data.component)");
|
|
769
796
|
expect(runtime).toContain("createElement(Suspense, { fallback: createElement(Loading, props) }, tree)");
|
|
797
|
+
// The metadata slot the server renders beside every page, empty in the
|
|
798
|
+
// browser, from the same module the server uses (ssr-page-tree.ts).
|
|
799
|
+
expect(runtime).toContain('import { withMetaSlot } from "./page-tree";');
|
|
800
|
+
expect(runtime).toContain("tree = withMetaSlot(createElement, Fragment, null, tree);");
|
|
801
|
+
expect(fs.readFileSync(path.join(tempDir, ".pylon", "page-tree.ts"), "utf8")).toBe(
|
|
802
|
+
fs.readFileSync(path.join(import.meta.dir, "ssr-page-tree.ts"), "utf8"),
|
|
803
|
+
);
|
|
770
804
|
});
|
|
771
805
|
|
|
772
806
|
test("an app with no loading.tsx still builds and navigates", async () => {
|
|
@@ -93,7 +93,7 @@ type BunBuildOutput = {
|
|
|
93
93
|
type BunPluginBuild = {
|
|
94
94
|
onResolve(
|
|
95
95
|
opts: { filter: RegExp; namespace?: string },
|
|
96
|
-
callback: (args: { path: string; importer: string }) => void,
|
|
96
|
+
callback: (args: { path: string; importer: string }) => { path: string; namespace: string } | undefined | void,
|
|
97
97
|
): void;
|
|
98
98
|
onLoad(
|
|
99
99
|
opts: { filter: RegExp; namespace?: string },
|
|
@@ -355,13 +355,14 @@ ${entries}
|
|
|
355
355
|
const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
|
|
356
356
|
// DO NOT EDIT — overwritten on every pylon dev / build.
|
|
357
357
|
|
|
358
|
-
import { createElement, Suspense } from "react";
|
|
358
|
+
import { createElement, Fragment, Suspense } from "react";
|
|
359
359
|
import { hydrateRoot } from "react-dom/client";
|
|
360
360
|
import { configureClient } from "@pylonsync/react";
|
|
361
361
|
import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
|
|
362
362
|
import { LOADING_MODULES } from "./loading-registry";
|
|
363
363
|
import { createNavPayloadCache } from "./nav-cache";
|
|
364
364
|
import { matchRoute, prefetchTargets } from "./route-match";
|
|
365
|
+
import { withMetaSlot } from "./page-tree";
|
|
365
366
|
|
|
366
367
|
const routeCache = Object.create(null);
|
|
367
368
|
let activeRoot = null;
|
|
@@ -387,6 +388,9 @@ function buildTree(Page, Layouts, props, loadingFor) {
|
|
|
387
388
|
if (Loading) {
|
|
388
389
|
tree = createElement(Suspense, { fallback: createElement(Loading, props) }, tree);
|
|
389
390
|
}
|
|
391
|
+
// The server renders the page's metadata in this slot; the browser leaves
|
|
392
|
+
// it empty. Same shape on both sides, so useId values match (page-tree.ts).
|
|
393
|
+
tree = withMetaSlot(createElement, Fragment, null, tree);
|
|
390
394
|
for (let i = Layouts.length - 1; i >= 0; i--) {
|
|
391
395
|
const Layout = Layouts[i];
|
|
392
396
|
if (!Layout) continue;
|
|
@@ -1568,6 +1572,14 @@ export async function _doBuildInner(
|
|
|
1568
1572
|
"utf8",
|
|
1569
1573
|
);
|
|
1570
1574
|
|
|
1575
|
+
// Same pattern for the page's tree shape, which the server render
|
|
1576
|
+
// shares (ssr-page-tree.ts): the runtime imports it as `./page-tree`.
|
|
1577
|
+
fs.writeFileSync(
|
|
1578
|
+
path.join(stageDir, "page-tree.ts"),
|
|
1579
|
+
fs.readFileSync(path.join(here, "ssr-page-tree.ts"), "utf8"),
|
|
1580
|
+
"utf8",
|
|
1581
|
+
);
|
|
1582
|
+
|
|
1571
1583
|
// Same pattern for the optimistic-navigation route matcher — the runtime
|
|
1572
1584
|
// imports it as `./route-match`; its source of truth is ssr-route-match.ts,
|
|
1573
1585
|
// unit-tested directly.
|
|
@@ -2081,41 +2093,23 @@ function makeBuildId(): string {
|
|
|
2081
2093
|
return `${process.pid.toString(36)}-${Date.now().toString(36)}-${_buildCounter}`;
|
|
2082
2094
|
}
|
|
2083
2095
|
|
|
2084
|
-
/**
|
|
2085
|
-
*
|
|
2086
|
-
* `<script type="module">`, `<link rel="stylesheet">`, and `modulepreload`s)
|
|
2087
|
-
* and to client-side chunk loads — baked into the bundle manifest as
|
|
2088
|
-
* `public_prefix`.
|
|
2089
|
-
*
|
|
2090
|
-
* Defaults to `/_pylon/build/`, which the Rust host serves from the build's
|
|
2091
|
-
* local `outdir`. That keeps dev and any deploy that doesn't opt in unchanged.
|
|
2092
|
-
*
|
|
2093
|
-
* Set `PYLON_PUBLIC_PREFIX` to an ABSOLUTE base (a CDN / object-storage URL for
|
|
2094
|
-
* this build's content-hashed assets, e.g. `https://assets.pyln.dev/<build>/`)
|
|
2095
|
-
* to serve assets off the app machines entirely. This is what makes hashed
|
|
2096
|
-
* assets survive an artifact prune and resolve IDENTICALLY across every Fly
|
|
2097
|
-
* machine: the entry filename SSR emits becomes an absolute URL that always
|
|
2098
|
-
* exists, instead of a machine-local path a prune/rollover can 404 (the bug
|
|
2099
|
-
* where a stale machine emitted `client-entry-…-<oldhash>.js` after that file
|
|
2100
|
-
* was pruned, breaking hydration). A trailing slash is enforced so
|
|
2101
|
-
* `${prefix}${file}` always joins correctly.
|
|
2102
|
-
*/
|
|
2103
|
-
/** Where the Rust host serves the client build (`/_pylon/build/<path>`). The
|
|
2104
|
-
* manifest's `public_prefix` and the bundler's `publicPath` must agree. */
|
|
2096
|
+
/** Where the Rust host serves the client build (`/_pylon/build/<path>`): the
|
|
2097
|
+
* manifest's `public_prefix`, and the prefix of imported assets' URLs. */
|
|
2105
2098
|
const BUILD_PREFIX = "/_pylon/build/";
|
|
2106
2099
|
|
|
2107
2100
|
/** Files a page can import as a URL (Bun's `file` loader). */
|
|
2108
2101
|
const ASSET_RE = /\.(wasm|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf|mp3|mp4|webm|wav|ogg)$/i;
|
|
2109
2102
|
|
|
2110
2103
|
/**
|
|
2111
|
-
*
|
|
2112
|
-
* prefix. Bun's
|
|
2104
|
+
* Assets JavaScript imports (a `.wasm`, an image) as absolute URLs under the
|
|
2105
|
+
* build prefix. Bun's `file` loader exports a RELATIVE path ("assets/x.wasm"),
|
|
2113
2106
|
* and code that fetches it resolves it against the page: loro-crdt's browser
|
|
2114
2107
|
* build fetched /dashboard/orgs/acme/assets/loro_wasm_bg-….wasm and 404'd.
|
|
2115
2108
|
* Bun's `publicPath` would make it absolute, but it also rewrites imports
|
|
2116
2109
|
* between split chunks and drops their `chunks/` directory (Bun 1.3.14), so
|
|
2117
2110
|
* this plugin writes the file itself, named by its content hash, and exports
|
|
2118
|
-
* the absolute URL.
|
|
2111
|
+
* the absolute URL. A stylesheet's `url(...)` is left to Bun: CSS cannot
|
|
2112
|
+
* import a JavaScript module.
|
|
2119
2113
|
*/
|
|
2120
2114
|
function absoluteAssetPlugin(
|
|
2121
2115
|
outdir: string,
|
|
@@ -2123,10 +2117,19 @@ function absoluteAssetPlugin(
|
|
|
2123
2117
|
path: typeof import("node:path"),
|
|
2124
2118
|
createHash: typeof import("node:crypto").createHash,
|
|
2125
2119
|
): { name: string; setup(build: BunPluginBuild): void } {
|
|
2120
|
+
const NAMESPACE = "pylon-asset";
|
|
2126
2121
|
return {
|
|
2127
2122
|
name: "pylon-absolute-assets",
|
|
2128
2123
|
setup(build) {
|
|
2129
|
-
build.
|
|
2124
|
+
build.onResolve({ filter: ASSET_RE }, (args) => {
|
|
2125
|
+
if (!args.importer || /\.css$/i.test(args.importer)) return undefined;
|
|
2126
|
+
const from = path.dirname(args.importer);
|
|
2127
|
+
const resolved = args.path.startsWith(".") || path.isAbsolute(args.path)
|
|
2128
|
+
? path.resolve(from, args.path)
|
|
2129
|
+
: ((Bun as any).resolveSync(args.path, from) as string);
|
|
2130
|
+
return { path: resolved, namespace: NAMESPACE };
|
|
2131
|
+
});
|
|
2132
|
+
build.onLoad({ filter: /.*/, namespace: NAMESPACE }, (args) => {
|
|
2130
2133
|
const bytes = fs.readFileSync(args.path);
|
|
2131
2134
|
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
|
|
2132
2135
|
const ext = path.extname(args.path);
|
|
@@ -2139,6 +2142,25 @@ function absoluteAssetPlugin(
|
|
|
2139
2142
|
};
|
|
2140
2143
|
}
|
|
2141
2144
|
|
|
2145
|
+
/**
|
|
2146
|
+
* Base URL/path prepended to every SSR-emitted asset reference (the entry
|
|
2147
|
+
* `<script type="module">`, `<link rel="stylesheet">`, and `modulepreload`s)
|
|
2148
|
+
* and to client-side chunk loads — baked into the bundle manifest as
|
|
2149
|
+
* `public_prefix`.
|
|
2150
|
+
*
|
|
2151
|
+
* Defaults to `/_pylon/build/`, which the Rust host serves from the build's
|
|
2152
|
+
* local `outdir`. That keeps dev and any deploy that doesn't opt in unchanged.
|
|
2153
|
+
*
|
|
2154
|
+
* Set `PYLON_PUBLIC_PREFIX` to an ABSOLUTE base (a CDN / object-storage URL for
|
|
2155
|
+
* this build's content-hashed assets, e.g. `https://assets.pyln.dev/<build>/`)
|
|
2156
|
+
* to serve assets off the app machines entirely. This is what makes hashed
|
|
2157
|
+
* assets survive an artifact prune and resolve IDENTICALLY across every Fly
|
|
2158
|
+
* machine: the entry filename SSR emits becomes an absolute URL that always
|
|
2159
|
+
* exists, instead of a machine-local path a prune/rollover can 404 (the bug
|
|
2160
|
+
* where a stale machine emitted `client-entry-…-<oldhash>.js` after that file
|
|
2161
|
+
* was pruned, breaking hydration). A trailing slash is enforced so
|
|
2162
|
+
* `${prefix}${file}` always joins correctly.
|
|
2163
|
+
*/
|
|
2142
2164
|
function resolvePublicPrefix(): string {
|
|
2143
2165
|
const raw = (process.env.PYLON_PUBLIC_PREFIX ?? "").trim();
|
|
2144
2166
|
if (!raw) return "/_pylon/build/";
|
|
@@ -21,6 +21,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
21
21
|
import { Window } from "happy-dom";
|
|
22
22
|
import React, { Suspense, use } from "react";
|
|
23
23
|
import { renderToReadableStream } from "react-dom/server.browser";
|
|
24
|
+
import { withMetaSlot } from "./ssr-page-tree";
|
|
24
25
|
|
|
25
26
|
// Mirrors `fulfilledThenable` in ssr-client-bundler.ts's CLIENT_RUNTIME_SOURCE:
|
|
26
27
|
// a React-recognized fulfilled thenable so use() reads `.value` synchronously
|
|
@@ -238,3 +239,93 @@ describe("route-level loading.tsx hydration", () => {
|
|
|
238
239
|
expect(errors.length).toBeGreaterThan(0);
|
|
239
240
|
});
|
|
240
241
|
});
|
|
242
|
+
|
|
243
|
+
// Every page with metadata had different useId values on the server and in
|
|
244
|
+
// the browser: the server put the metadata beside the page, the browser
|
|
245
|
+
// hydrated the page alone. Radix menus, dialogs and form labels then
|
|
246
|
+
// mismatched. Both sides now share withMetaSlot (ssr-page-tree.ts).
|
|
247
|
+
describe("metadata slot keeps useId values stable", () => {
|
|
248
|
+
function Layout({ children }: { children?: any }) {
|
|
249
|
+
return React.createElement("main", { id: "layout" }, children);
|
|
250
|
+
}
|
|
251
|
+
function Control() {
|
|
252
|
+
const id = React.useId();
|
|
253
|
+
return React.createElement("button", { id: `menu-${id}`, "data-probe": "" }, "Menu");
|
|
254
|
+
}
|
|
255
|
+
function MenuPage() {
|
|
256
|
+
return React.createElement("div", { id: "page" }, React.createElement(Control));
|
|
257
|
+
}
|
|
258
|
+
const meta = React.createElement("meta", { name: "description", content: "A page" });
|
|
259
|
+
|
|
260
|
+
async function render(tree: any): Promise<string> {
|
|
261
|
+
const stream = await renderToReadableStream(tree);
|
|
262
|
+
await (stream as any).allReady;
|
|
263
|
+
const reader = stream.getReader();
|
|
264
|
+
let html = "";
|
|
265
|
+
for (;;) {
|
|
266
|
+
const { value, done } = await reader.read();
|
|
267
|
+
if (done) break;
|
|
268
|
+
html += dec.decode(value);
|
|
269
|
+
}
|
|
270
|
+
return html;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function hydrate(serverTree: any, clientTree: any) {
|
|
274
|
+
const html = await render(serverTree);
|
|
275
|
+
const serverId = /id="(menu-[^"]+)"/.exec(html)?.[1];
|
|
276
|
+
const win = new Window({ url: "http://localhost/" });
|
|
277
|
+
const root = win.document.createElement("div");
|
|
278
|
+
root.insertAdjacentHTML("afterbegin", html);
|
|
279
|
+
win.document.body.appendChild(root);
|
|
280
|
+
registerDom(win);
|
|
281
|
+
const errors: string[] = [];
|
|
282
|
+
const origErr = console.error;
|
|
283
|
+
console.error = (...a: any[]) => {
|
|
284
|
+
errors.push(a.map(String).join(" "));
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
const { hydrateRoot } = await import("react-dom/client");
|
|
288
|
+
hydrateRoot(root as any, clientTree, {
|
|
289
|
+
onRecoverableError: (e: any) => errors.push(String(e?.message ?? e)),
|
|
290
|
+
});
|
|
291
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
292
|
+
} finally {
|
|
293
|
+
console.error = origErr;
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
serverId,
|
|
297
|
+
errors: errors.filter((e) => /hydrat|did not match|mismatch/i.test(e)),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const e = React.createElement;
|
|
302
|
+
|
|
303
|
+
test("the server's slot with metadata and the browser's empty slot give the same ids", async () => {
|
|
304
|
+
// What the browser's ids are, rendered alone with the client shape.
|
|
305
|
+
const clientHtml = await render(e(Layout, null, withMetaSlot(e, React.Fragment, null, e(MenuPage))));
|
|
306
|
+
const clientId = /id="(menu-[^"]+)"/.exec(clientHtml)?.[1];
|
|
307
|
+
const { serverId, errors } = await hydrate(
|
|
308
|
+
e(Layout, null, withMetaSlot(e, React.Fragment, meta, e(MenuPage))),
|
|
309
|
+
e(Layout, null, withMetaSlot(e, React.Fragment, null, e(MenuPage))),
|
|
310
|
+
);
|
|
311
|
+
expect(serverId).toBeDefined();
|
|
312
|
+
expect(serverId).toBe(clientId);
|
|
313
|
+
expect(errors).toEqual([]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("a page without metadata has the same shape", async () => {
|
|
317
|
+
const { errors } = await hydrate(
|
|
318
|
+
e(Layout, null, withMetaSlot(e, React.Fragment, null, e(MenuPage))),
|
|
319
|
+
e(Layout, null, withMetaSlot(e, React.Fragment, null, e(MenuPage))),
|
|
320
|
+
);
|
|
321
|
+
expect(errors).toEqual([]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("hydrating the page without the slot mismatches (the bug)", async () => {
|
|
325
|
+
const { errors } = await hydrate(
|
|
326
|
+
e(Layout, null, e(React.Fragment, null, meta, e(MenuPage))),
|
|
327
|
+
e(Layout, null, e(MenuPage)),
|
|
328
|
+
);
|
|
329
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The shape of the tree around a route's page, shared by the server render
|
|
2
|
+
// (ssr-runtime.ts) and the browser's hydration (the generated client runtime
|
|
3
|
+
// imports this file as `./page-tree`).
|
|
4
|
+
//
|
|
5
|
+
// React derives `useId` values from the tree's shape, including how many
|
|
6
|
+
// children each element has. The server renders the page's metadata (<title>,
|
|
7
|
+
// <meta>, <link>, which React hoists into <head>) beside the page; the browser
|
|
8
|
+
// never renders those tags. So both sides give the page a fragment with two
|
|
9
|
+
// slots: on the server the first slot holds the metadata, in the browser it
|
|
10
|
+
// holds null. React counts a null slot, so every id inside the page is the
|
|
11
|
+
// same on both sides. Without the slot, every Radix menu, dialog and label id
|
|
12
|
+
// on a page with metadata mismatched on hydration.
|
|
13
|
+
//
|
|
14
|
+
// No imports: the client bundler copies this file next to the runtime.
|
|
15
|
+
|
|
16
|
+
type CreateElement = (type: any, props: any, ...children: any[]) => any;
|
|
17
|
+
|
|
18
|
+
/** The page with its metadata slot. `meta` is null in the browser. */
|
|
19
|
+
export function withMetaSlot(createElement: CreateElement, Fragment: any, meta: unknown, page: unknown): any {
|
|
20
|
+
return createElement(Fragment, null, meta ?? null, page);
|
|
21
|
+
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
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 { withMetaSlot } from "./ssr-page-tree";
|
|
10
11
|
import { moduleKey, serverBundle } from "./server-bundle";
|
|
11
12
|
|
|
12
13
|
// Bun runtime global. This module runs under Bun but is type-checked by
|
|
@@ -2561,15 +2562,14 @@ async function tryRenderBoundary(
|
|
|
2561
2562
|
);
|
|
2562
2563
|
boundaryMeta = applyAutoIcons(rel, boundaryMeta);
|
|
2563
2564
|
const boundaryMetaFragment = renderMetadata(React, boundaryMeta);
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
}
|
|
2565
|
+
// The same metadata slot a page gets: the browser hydrates a boundary
|
|
2566
|
+
// with the page's tree shape (ssr-page-tree.ts).
|
|
2567
|
+
let tree = withMetaSlot(
|
|
2568
|
+
React.createElement,
|
|
2569
|
+
React.Fragment,
|
|
2570
|
+
boundaryMetaFragment,
|
|
2571
|
+
React.createElement(Comp, compProps),
|
|
2572
|
+
);
|
|
2573
2573
|
tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
|
|
2574
2574
|
await renderBoundaryToClient(
|
|
2575
2575
|
React,
|
|
@@ -3563,11 +3563,10 @@ export async function handleRenderRoute(
|
|
|
3563
3563
|
// root: start with the page component as `tree`, then for each
|
|
3564
3564
|
// layout (innermost first) wrap it as the new tree. Result is
|
|
3565
3565
|
// the outermost layout containing all nested layouts down to
|
|
3566
|
-
// the page. The metadata
|
|
3567
|
-
//
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
: pageLeaf;
|
|
3566
|
+
// the page. The metadata is the FIRST child so React hoists its
|
|
3567
|
+
// <title>/<meta> into the <head> a layout renders; the browser renders
|
|
3568
|
+
// the same slot empty (ssr-page-tree.ts), so useId values match.
|
|
3569
|
+
let tree: any = withMetaSlot(React.createElement, React.Fragment, metaFragment, pageLeaf);
|
|
3571
3570
|
tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
|
|
3572
3571
|
const element = tree;
|
|
3573
3572
|
// Tracks whether the HTTP head (response_start) has actually gone out. A
|
package/src/types.ts
CHANGED
|
@@ -1044,6 +1044,30 @@ export type RequireMember = (
|
|
|
1044
1044
|
* });
|
|
1045
1045
|
* ```
|
|
1046
1046
|
*/
|
|
1047
|
+
/**
|
|
1048
|
+
* `ctx.crdt` (actions only): read and change a row's live CRDT document, the
|
|
1049
|
+
* one editors bind to with `@pylonsync/loro`. Bytes are Loro's own formats.
|
|
1050
|
+
* `@pylonsync/loro/server` wraps this in a `LoroDoc` (`openLiveDoc`).
|
|
1051
|
+
*
|
|
1052
|
+
* Each `apply` is its own write: it merges, then reaches everyone editing at
|
|
1053
|
+
* once, so it cannot roll back with a mutation (queries and mutations get
|
|
1054
|
+
* CRDT_ACTIONS_ONLY). Policies follow the `ctx.db` rule: they apply under
|
|
1055
|
+
* strict function policies (`PYLON_STRICT_FN_POLICIES=1`), except through
|
|
1056
|
+
* `ctx.crdt.unsafe`. Without strict policies, check access yourself.
|
|
1057
|
+
*/
|
|
1058
|
+
export interface Crdt {
|
|
1059
|
+
/** The row's whole document, or null when the row does not exist (or, under
|
|
1060
|
+
* strict policies, the caller may not read it). */
|
|
1061
|
+
snapshot(entity: string, id: string): Promise<Uint8Array | null>;
|
|
1062
|
+
/** The changes since `version` (a Loro version vector, `doc.oplogVersion().encode()`),
|
|
1063
|
+
* or null as for {@link Crdt.snapshot}. */
|
|
1064
|
+
since(entity: string, id: string, version: Uint8Array): Promise<Uint8Array | null>;
|
|
1065
|
+
/** Merge a Loro update into the row and broadcast it. */
|
|
1066
|
+
apply(entity: string, id: string, update: Uint8Array): Promise<void>;
|
|
1067
|
+
/** The same operations with server trust even under strict policies. */
|
|
1068
|
+
unsafe: Omit<Crdt, "unsafe">;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1047
1071
|
export interface Files {
|
|
1048
1072
|
/**
|
|
1049
1073
|
* Mint a signed download path for a file id. `ttlSecs` defaults to 300
|
|
@@ -1542,6 +1566,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
1542
1566
|
env: Record<string, string>;
|
|
1543
1567
|
/** Signed file-download URLs — see {@link Files}. */
|
|
1544
1568
|
files: ActionFiles;
|
|
1569
|
+
/** A row's live CRDT document, as Loro bytes — see {@link Crdt}. */
|
|
1570
|
+
crdt: Crdt;
|
|
1545
1571
|
/** Shard tickets, reads, and start/stop — see {@link Shards}. */
|
|
1546
1572
|
shards: Shards;
|
|
1547
1573
|
/** Run a registered query within its own read transaction. */
|