@pylonsync/functions 0.11.6 → 0.13.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ /** The manifest's `build` block, as the SDK writes it. */
2
+ export interface ManifestBuildConfig {
3
+ target?: string | string[];
4
+ polyfill?: false | "usage" | "entry";
5
+ css?: {
6
+ target?: string | string[];
7
+ };
8
+ sourcemap?: boolean;
9
+ server?: {
10
+ bundle?: boolean;
11
+ external?: string[];
12
+ };
13
+ include?: string[];
14
+ }
15
+ /** Resolved client compatibility settings. `null` means Bun's output ships
16
+ * unchanged (no target set). */
17
+ export interface ClientCompat {
18
+ /** Browserslist queries for JS. Empty when only `css.target` is set. */
19
+ jsTargets: string[];
20
+ /** Browserslist queries for CSS. */
21
+ cssTargets: string[];
22
+ polyfill: false | "usage" | "entry";
23
+ sourcemap: boolean;
24
+ }
25
+ /**
26
+ * Browser versions for each ECMAScript edition target. `es2019` becomes the
27
+ * oldest browsers with full ES2019 syntax support, so SWC and Lightning CSS
28
+ * (which work from browser versions) can use it.
29
+ */
30
+ export declare const ES_TARGET_BROWSERS: Record<string, string[]>;
31
+ /**
32
+ * The oldest version of each browser that can run the Pylon client runtime.
33
+ * The runtime ships as ES modules and loads route entries with dynamic
34
+ * `import()`. Syntax lowering cannot make an older browser load a page.
35
+ */
36
+ export declare const RUNTIME_BROWSER_FLOOR: Record<string, number>;
37
+ /** Normalize a `target` value to browserslist queries. Throws on a value the
38
+ * build cannot honor. */
39
+ export declare function targetQueries(target: string | string[], field: string): string[];
40
+ /**
41
+ * Resolve the client compatibility settings from the manifest's `build`
42
+ * block. Returns null when no JS or CSS target is set. `polyfill` without
43
+ * `target` is an error: it needs the browsers to polyfill for.
44
+ */
45
+ export declare function resolveClientCompat(build: ManifestBuildConfig | undefined): ClientCompat | null;
46
+ /** The browsers (from a browserslist result) that cannot run the client
47
+ * runtime. Browsers with no known floor are included. */
48
+ export declare function browsersBelowFloor(browsers: string[]): string[];
49
+ /** Remove core-js feature imports from `code` and add them to `into`. */
50
+ export declare function extractCoreJsImports(code: string, into: Set<string>): string;
51
+ /** Give a hashed file name (`name-<hash>.js`) a new hash derived from its old
52
+ * one and `salt`. Names without a hash are returned unchanged. */
53
+ export declare function rehashName(base: string, salt: string, hasher: (s: string) => string): string;
54
+ export interface CompatResult {
55
+ /** Old outdir-relative path → new outdir-relative path, for every renamed
56
+ * output. */
57
+ renamed: Map<string, string>;
58
+ /** Polyfill bundle, outdir-relative. Absent when polyfill is off or the
59
+ * targets need none. */
60
+ polyfills?: string;
61
+ /** Browsers the targets include that cannot run the client runtime. */
62
+ unsupported: string[];
63
+ }
64
+ /**
65
+ * Lower every .js output under `outdir` to the JS targets, collect and bundle
66
+ * polyfills, and rename the outputs. `jsFiles` are outdir-relative,
67
+ * "/"-separated paths of Bun's .js outputs.
68
+ */
69
+ export declare function applyClientCompat(opts: {
70
+ fs: any;
71
+ path: any;
72
+ cwd: string;
73
+ outdir: string;
74
+ jsFiles: string[];
75
+ compat: ClientCompat;
76
+ }): Promise<CompatResult>;
77
+ /**
78
+ * Apply Lightning CSS to compiled CSS for the CSS targets: vendor prefixes,
79
+ * nesting and color-syntax lowering, minification.
80
+ */
81
+ export declare function transformCss(cwd: string, css: string, filename: string, cssTargets: string[]): Promise<string>;
package/dist/index.d.ts CHANGED
@@ -26,4 +26,4 @@ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunne
26
26
  export { resetDb, installTestIsolation } from "./testing";
27
27
  export { slugifyName, availableSlug } from "./slugify";
28
28
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
29
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Workflows, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
29
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardInfo, MemberRow, Workflows, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
@@ -0,0 +1,47 @@
1
+ /** File name of the build info file. Its presence marks an artifact dir. */
2
+ export declare const BUILD_INFO_FILE = "pylon-build.json";
3
+ export interface ProductionBuildOptions {
4
+ /** Project root. */
5
+ cwd: string;
6
+ /** Absolute output directory. */
7
+ outDir: string;
8
+ /** App (routes) directory, relative to cwd. Default `app`. */
9
+ appDir?: string;
10
+ /** Functions directory, relative to cwd. Default `functions`. */
11
+ functionsDir?: string;
12
+ /** The app manifest. Default `<cwd>/pylon.manifest.json`. */
13
+ manifestPath?: string;
14
+ }
15
+ export interface ProductionBuildResult {
16
+ outDir: string;
17
+ functions: number;
18
+ workflows: number;
19
+ appModules: number;
20
+ clientRoutes: number;
21
+ externals: string[];
22
+ polyfills: boolean;
23
+ }
24
+ export declare function buildProduction(opts: ProductionBuildOptions): Promise<ProductionBuildResult>;
25
+ /** The artifact-relative path for one `build.include` entry. Throws when the
26
+ * entry cannot be copied into the artifact as-is. */
27
+ export declare function checkInclude(path: any, cwd: string, outDir: string, inc: string, src: string, label?: string): string;
28
+ /**
29
+ * Every app module the SSR runtime can import: files named by convention
30
+ * anywhere under the app dir, plus every component and layout the manifest
31
+ * routes name. Keys are the runtime's module paths (`app/blog/page`).
32
+ */
33
+ export declare function collectAppModules(fs: any, path: any, cwd: string, appDir: string, manifest: any): Array<{
34
+ key: string;
35
+ file: string;
36
+ }>;
37
+ /**
38
+ * Copy `roots` and their dependency closure (dependencies, optional
39
+ * dependencies that are installed, and peers) into `dest` (a node_modules
40
+ * dir).
41
+ * Each package resolves its own dependencies from its install location, so
42
+ * npm, pnpm, and Bun layouts all work. A package goes to the top of `dest`
43
+ * unless another version already holds that name; then it nests under the
44
+ * dependent package's own `node_modules`, where run-time resolution finds it
45
+ * first. Returns the copied package names.
46
+ */
47
+ export declare function copyPackageClosure(fs: any, path: any, cwd: string, roots: string[], dest: string): string[];
@@ -0,0 +1,31 @@
1
+ /** Loads one bundled module and returns its namespace. */
2
+ export type ModuleLoader = () => Promise<any>;
3
+ export interface PylonServerBundle {
4
+ /** Function name (file name without extension) → module loader. */
5
+ functions: Record<string, ModuleLoader>;
6
+ /** Workflow file name (without extension) → module loader. */
7
+ workflows: Record<string, ModuleLoader>;
8
+ /** Project-relative, extension-less, "/"-separated module path
9
+ * (`app/blog/page`) → module loader. Holds every module under the app
10
+ * dir that the SSR runtime can import: pages, layouts, boundaries,
11
+ * loading states, route handlers, OG image modules, metadata routes. */
12
+ modules: Record<string, ModuleLoader>;
13
+ /** The app's React and React DOM server, as bundled with the pages, so SSR
14
+ * renders with the same React instance the page modules import. */
15
+ react: any;
16
+ reactDomServer: any;
17
+ /** Client bundle directory, relative to the artifact root. */
18
+ clientDir: string;
19
+ /** Absolute paths of the files the OG image renderer reads at run time.
20
+ * In source mode it finds them next to its own source file and in
21
+ * `node_modules`; the bundle copies them next to the server output. */
22
+ ogAssets: {
23
+ resvgWasm: string;
24
+ interRegular: string;
25
+ interSemiBold: string;
26
+ };
27
+ }
28
+ export declare function serverBundle(): PylonServerBundle | null;
29
+ export declare function setServerBundle(bundle: PylonServerBundle): void;
30
+ /** Normalize a module path to the registry key form. */
31
+ export declare function moduleKey(relPath: string): string;
@@ -1,4 +1,5 @@
1
1
  import { type ManifestFonts } from "./ssr-fonts";
2
+ import { type ManifestBuildConfig } from "./build-compat";
2
3
  type Send = (msg: Record<string, unknown>) => void;
3
4
  interface BundleClientMessage {
4
5
  type: "bundle_client";
@@ -70,8 +71,12 @@ export declare function generateLoadingRegistry(components: string[]): string;
70
71
  export interface PylonBundleManifest {
71
72
  /** Build identity — bumps every successful build. */
72
73
  build_id: string;
73
- /** Output root, relative to cwd (always `.pylon/client-build`). */
74
+ /** Output root, relative to cwd: `.pylon/client-build`, or `client` in a
75
+ * `pylon build` artifact. */
74
76
  outdir: string;
77
+ /** core-js polyfill bundle (relative to outdir), loaded before any route
78
+ * entry. Present when `build.polyfill` is set and the targets need one. */
79
+ polyfills?: string;
75
80
  /** Public URL prefix the Rust host serves chunks under. */
76
81
  public_prefix: string;
77
82
  /** routeComponentPath → file + imports for that route. */
@@ -108,6 +113,29 @@ export interface BuildOutput {
108
113
  * from `getManifest` (in-process SSR path).
109
114
  */
110
115
  export declare function buildClientBundle(appDirRel?: string): Promise<BuildOutput>;
116
+ /** Default client bundle directory, relative to the project root. */
117
+ export declare const CLIENT_BUILD_DIR = ".pylon/client-build";
118
+ /** The client bundle directory, relative to cwd. A production server bundle
119
+ * names its own; source mode uses `CLIENT_BUILD_DIR`. */
120
+ export declare function clientBuildDir(): string;
121
+ export interface ClientBuildOptions {
122
+ /** Absolute output directory. Default: `<cwd>/.pylon/client-build`. */
123
+ outdir?: string;
124
+ /** The manifest's `outdir` value. Default: `outdir` relative to cwd. A
125
+ * `pylon build` artifact sets `client`, its path at run time. */
126
+ manifestOutdir?: string;
127
+ /** The `build` block to apply. Default: read from `pylon.manifest.json`,
128
+ * except in dev (`NODE_ENV=development`), which applies none. `null`
129
+ * applies none. */
130
+ buildConfig?: ManifestBuildConfig | null;
131
+ /** Fail on a CSS or font build error instead of shipping without it. */
132
+ strict?: boolean;
133
+ /** The app manifest (fonts, build settings). Default
134
+ * `<cwd>/pylon.manifest.json`. */
135
+ manifestPath?: string;
136
+ }
137
+ /** The `build` block of `<cwd>/pylon.manifest.json`, if any. */
138
+ export declare function readManifestBuildConfig(fs: any, path: any, cwd: string): ManifestBuildConfig | undefined;
111
139
  /**
112
140
  * Compile `app/globals.css` through Tailwind v4 (`@tailwindcss/cli`)
113
141
  * if both are present. Returns the relative output path (under
@@ -115,8 +143,8 @@ export declare function buildClientBundle(appDirRel?: string): Promise<BuildOutp
115
143
  * project hasn't opted in to Tailwind — we don't want every SSR
116
144
  * project to need Tailwind installed.
117
145
  */
118
- export declare function buildTailwind(fs: any, path: any, cwd: string, outdir: string, appDirRel: string): Promise<string | null>;
119
- export declare function _doBuildInner(fs: any, path: any, cwd: string, appDirRel: string): Promise<BuildOutput>;
146
+ export declare function buildTailwind(fs: any, path: any, cwd: string, outdir: string, appDirRel: string, cssTargets?: string[]): Promise<string | null>;
147
+ export declare function _doBuildInner(fs: any, path: any, cwd: string, appDirRel: string, opts?: ClientBuildOptions): Promise<BuildOutput>;
120
148
  /**
121
149
  * Return the bundle manifest. If a fresh manifest exists on disk,
122
150
  * use it (caching parse output across requests). Otherwise build
@@ -90,5 +90,5 @@ export declare function parseGoogleFontsCss(css: string, subsets: string[]): Fac
90
90
  export declare function buildFonts(fs: any, path: any, cwd: string, outdir: string, fonts: ManifestFontInput[]): Promise<ManifestFonts | null>;
91
91
  /** Read the `fonts` array from the app's `pylon.manifest.json` (written next to
92
92
  * app.ts). Returns [] when absent/unreadable. */
93
- export declare function readManifestFonts(fs: any, path: any, cwd: string): ManifestFontInput[];
93
+ export declare function readManifestFonts(fs: any, path: any, cwd: string, manifestPath?: string): ManifestFontInput[];
94
94
  export {};
@@ -313,8 +313,17 @@ export interface SsrMetadata {
313
313
  * there's no manual XSS handling. Returns null when there's nothing to emit.
314
314
  */
315
315
  export declare function renderMetadata(React: any, m: SsrMetadata | undefined): any;
316
- /** Import a project-relative module, trying each common extension. */
316
+ /**
317
+ * Import a project-relative module, trying each common extension. A
318
+ * production server bundle serves it from the bundle's module registry.
319
+ */
317
320
  export declare function importModule(cwd: string, relPath: string): Promise<any>;
321
+ /**
322
+ * True when the project has a module at `<dir>/<name>` with any code
323
+ * extension. `dir` is project-relative and "/"-separated. A production server
324
+ * bundle answers from its module registry; source mode checks the disk.
325
+ */
326
+ export declare function moduleExistsIn(fs: any, path: any, cwd: string, dir: string, name: string): boolean;
318
327
  /**
319
328
  * `findBoundary` with its filesystem and root injected, so the walk is
320
329
  * testable against a fixture without `chdir` — which races every other test
@@ -442,6 +451,9 @@ export declare function buildHydrationTail(args: {
442
451
  } | null;
443
452
  publicPrefix: string;
444
453
  manifestErr: string | null;
454
+ /** core-js polyfill bundle (relative to `publicPrefix`), from the bundle
455
+ * manifest. Loaded before the route entry. */
456
+ polyfills?: string;
445
457
  kind?: "error" | "not-found";
446
458
  errorForClient?: {
447
459
  message: string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Redirect console.* from user code to stderr so handlers can't accidentally
3
+ * emit a line that looks like a protocol frame and confuse the Rust reader.
4
+ *
5
+ * Before this guard, a handler calling `console.log('{"type":"return",...}')`
6
+ * — either intentionally or by logging an object shaped that way — would be
7
+ * parsed by the host as a real protocol message. Moving all console output
8
+ * to stderr keeps stdout reserved for NDJSON protocol frames only.
9
+ *
10
+ * The original console methods are saved on the console object as
11
+ * `__stdoutLog` etc. in case the runtime itself needs to write diagnostics
12
+ * to stdout for some reason (it currently doesn't).
13
+ *
14
+ * Safe to call more than once. A production server bundle calls it from its
15
+ * entry, before the app modules load, and the runtime's `main()` calls it
16
+ * again.
17
+ */
18
+ export declare function fenceStdout(): void;
package/dist/types.d.ts CHANGED
@@ -731,6 +731,75 @@ export interface Files {
731
731
  ttlSecs?: number;
732
732
  }): Promise<string>;
733
733
  }
734
+ /**
735
+ * Shard tickets: short-lived, signed permission to join one realtime shard,
736
+ * with claims the shard's authorization hooks read without a database
737
+ * call. Check what the user may do here, then mint:
738
+ *
739
+ * ```ts
740
+ * export default mutation({
741
+ * args: { characterId: v.id("Character") },
742
+ * async handler(ctx, args) {
743
+ * const c = await ctx.db.get("Character", args.characterId);
744
+ * if (c?.ownerId !== ctx.auth.userId) throw ctx.error("FORBIDDEN", "not your character");
745
+ * const shard = `zone-${c.zone}`;
746
+ * const ticket = await ctx.shards.ticket(shard, {
747
+ * subscriberId: c.id,
748
+ * claims: { character: c.id, realm: c.realm },
749
+ * });
750
+ * return { shard, ticket };
751
+ * },
752
+ * });
753
+ * ```
754
+ *
755
+ * The client passes the ticket when it connects (`connectShard(shard, {
756
+ * ticket })`). The shard rejects a ticket for another shard, another
757
+ * subscriber id, or past its expiry.
758
+ */
759
+ export interface Shards {
760
+ /**
761
+ * Mint a ticket for `shardId`. `subscriberId` defaults to the calling
762
+ * user's id; `ttlSecs` defaults to 60 and is capped at 3600 by the host.
763
+ */
764
+ ticket(shardId: string, opts?: {
765
+ subscriberId?: string;
766
+ claims?: Record<string, unknown>;
767
+ ttlSecs?: number;
768
+ }): Promise<string>;
769
+ /**
770
+ * Start shard `shardId` of a kind declared with `shard({...})` in app.ts.
771
+ * `params` reach the module's `init`. Actions only: a mutation's
772
+ * rollback cannot undo it.
773
+ *
774
+ * Throws `SHARD_EXISTS` when the id is running, `SHARD_LIMIT_REACHED`
775
+ * at the kind's `maxInstances`, `SHARD_KIND_NOT_FOUND`,
776
+ * `SHARD_ID_INVALID`, or `SHARD_INIT_FAILED` when `init` refuses.
777
+ */
778
+ create(kind: string, shardId: string, params?: unknown): Promise<ShardInfo>;
779
+ /** Stop a shard and close its subscribers' connections. Resolves to
780
+ * `false` when no shard has that id. Actions only. */
781
+ stop(shardId: string): Promise<boolean>;
782
+ /** A running shard, or `null`. */
783
+ get(shardId: string): Promise<ShardInfo | null>;
784
+ /** Every running shard. */
785
+ list(): Promise<ShardInfo[]>;
786
+ }
787
+ /** `ctx.shards` in a query or mutation: tickets and reads, no start or stop. */
788
+ export type ShardsReader = Pick<Shards, "ticket" | "get" | "list">;
789
+ /** A running shard, from `ctx.shards.create`, `get`, or `list`. */
790
+ export interface ShardInfo {
791
+ id: string;
792
+ /** The shard kind's name. */
793
+ kind: string;
794
+ /** Ticks run so far. */
795
+ tick: number;
796
+ subscribers: number;
797
+ /** False once the shard stopped (finished, idle, or failed) and before
798
+ * the host removes it. */
799
+ running: boolean;
800
+ /** Why the module stopped, when it trapped. */
801
+ error?: string;
802
+ }
734
803
  /** Context for query handlers (read-only).
735
804
  *
736
805
  * NOTE: `ctx.llm` is NOT exposed here. Queries are reactive: a
@@ -756,6 +825,8 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
756
825
  requireMember: RequireMember;
757
826
  /** Signed file-download URLs — see {@link Files}. */
758
827
  files: Files;
828
+ /** Shard tickets and reads — see {@link Shards}. */
829
+ shards: ShardsReader;
759
830
  /**
760
831
  * Fires when the host cancels this call (idle timeout exceeded).
761
832
  * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
@@ -783,6 +854,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
783
854
  workflows: Workflows;
784
855
  /** Signed file-download URLs — see {@link Files}. */
785
856
  files: Files;
857
+ /** Shard tickets and reads — see {@link Shards}. */
858
+ shards: ShardsReader;
786
859
  /** Create a typed error that triggers rollback. */
787
860
  error(code: string, message: string): Error;
788
861
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
@@ -952,6 +1025,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
952
1025
  env: Record<string, string>;
953
1026
  /** Signed file-download URLs — see {@link Files}. */
954
1027
  files: Files;
1028
+ /** Shard tickets, reads, and start/stop — see {@link Shards}. */
1029
+ shards: Shards;
955
1030
  /** Run a registered query within its own read transaction. */
956
1031
  runQuery<T = unknown>(fnName: string, args: Record<string, unknown>): Promise<T>;
957
1032
  /** Run a registered mutation within its own write transaction. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.11.6",
3
+ "version": "0.13.0",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -52,11 +52,15 @@
52
52
  "typescript": "^5.5"
53
53
  },
54
54
  "devDependencies": {
55
- "react": "^19.0.0",
56
- "react-dom": "^19.0.0",
55
+ "@swc/core": "^1.16.2",
57
56
  "@types/react": "^19.0.0",
58
57
  "@types/react-dom": "^19.0.0",
59
- "happy-dom": "^15.0.0"
58
+ "browserslist": "^4.29.1",
59
+ "core-js": "^3.50.0",
60
+ "happy-dom": "^15.0.0",
61
+ "lightningcss": "~1.32.0",
62
+ "react": "^19.0.0",
63
+ "react-dom": "^19.0.0"
60
64
  },
61
65
  "peerDependencies": {
62
66
  "bun-types": "*"
@@ -0,0 +1,36 @@
1
+ // Entry the Rust CLI runs for `pylon build`:
2
+ //
3
+ // bun run <@pylonsync/functions>/src/build-cli.ts --out <dir>
4
+ // [--app-dir app] [--manifest <path to pylon.manifest.json>]
5
+ //
6
+ // Writes the artifact (see production-build.ts) and prints one JSON line with
7
+ // the result on stdout. Errors go to stderr with a non-zero exit.
8
+
9
+ import { buildProduction } from "./production-build";
10
+
11
+ function flag(name: string): string | undefined {
12
+ const i = process.argv.indexOf(name);
13
+ return i >= 0 ? process.argv[i + 1] : undefined;
14
+ }
15
+
16
+ // The React production build, and the same NODE_ENV the server bundle
17
+ // defines. Set before the bundler reads it.
18
+ process.env.NODE_ENV = "production";
19
+
20
+ const path = await import("node:path");
21
+ const cwd = process.cwd();
22
+ const out = flag("--out") ?? "dist";
23
+
24
+ try {
25
+ const result = await buildProduction({
26
+ cwd,
27
+ outDir: path.resolve(cwd, out),
28
+ appDir: flag("--app-dir") ?? "app",
29
+ manifestPath: flag("--manifest"),
30
+ functionsDir: process.env.PYLON_FUNCTIONS_DIR ?? "functions",
31
+ });
32
+ process.stdout.write(JSON.stringify({ ok: true, ...result }) + "\n");
33
+ } catch (e: any) {
34
+ process.stderr.write(`${e?.message ?? String(e)}\n`);
35
+ process.exit(1);
36
+ }
@@ -0,0 +1,173 @@
1
+ // Client browser compatibility (build.target / polyfill / css.target).
2
+ //
3
+ // The pure helpers are tested directly. `applyClientCompat` and
4
+ // `transformCss` run the real tools (SWC, browserslist, core-js, Lightning
5
+ // CSS), resolved from this package's devDependencies, against files in a
6
+ // temp dir.
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ import {
14
+ applyClientCompat,
15
+ browsersBelowFloor,
16
+ extractCoreJsImports,
17
+ rehashName,
18
+ resolveClientCompat,
19
+ targetQueries,
20
+ transformCss,
21
+ } from "./build-compat";
22
+
23
+ const PKG_DIR = path.resolve(import.meta.dir, "..");
24
+ let tmp: string | null = null;
25
+ afterEach(() => {
26
+ if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
27
+ tmp = null;
28
+ });
29
+
30
+ describe("targetQueries", () => {
31
+ test("an ECMAScript edition becomes browser versions", () => {
32
+ expect(targetQueries("es2019", "build.target")).toContain("safari 12.1");
33
+ });
34
+
35
+ test("browserslist queries pass through", () => {
36
+ expect(targetQueries(["safari >= 14", "chrome >= 90"], "t")).toEqual([
37
+ "safari >= 14",
38
+ "chrome >= 90",
39
+ ]);
40
+ });
41
+
42
+ test("editions below es2018 are rejected: the runtime needs ES modules", () => {
43
+ expect(() => targetQueries("es2015", "build.target")).toThrow("es2018");
44
+ });
45
+
46
+ test("empty values are rejected", () => {
47
+ expect(() => targetQueries([], "build.target")).toThrow("must not be empty");
48
+ expect(() => targetQueries([" "], "build.target")).toThrow("non-empty");
49
+ });
50
+ });
51
+
52
+ describe("resolveClientCompat", () => {
53
+ test("no target and no css target → no compat pass", () => {
54
+ expect(resolveClientCompat(undefined)).toBeNull();
55
+ expect(resolveClientCompat({ server: { external: ["x"] } })).toBeNull();
56
+ });
57
+
58
+ test("css.target defaults to target", () => {
59
+ const c = resolveClientCompat({ target: "es2020" })!;
60
+ expect(c.cssTargets).toEqual(c.jsTargets);
61
+ });
62
+
63
+ test("css.target alone lowers CSS only", () => {
64
+ const c = resolveClientCompat({ css: { target: "safari >= 13" } })!;
65
+ expect(c.jsTargets).toEqual([]);
66
+ expect(c.cssTargets).toEqual(["safari >= 13"]);
67
+ });
68
+
69
+ test("polyfill without target is an error", () => {
70
+ expect(() => resolveClientCompat({ polyfill: "usage" })).toThrow("needs build.target");
71
+ });
72
+ });
73
+
74
+ test("extractCoreJsImports strips minified and spaced forms", () => {
75
+ const found = new Set<string>();
76
+ const code =
77
+ 'import"core-js/modules/es.array.at.js";import "core-js/modules/es.object.has-own.js";\nconst a=1;';
78
+ expect(extractCoreJsImports(code, found).trim()).toBe("const a=1;");
79
+ expect([...found].sort()).toEqual([
80
+ "core-js/modules/es.array.at.js",
81
+ "core-js/modules/es.object.has-own.js",
82
+ ]);
83
+ });
84
+
85
+ test("rehashName changes the hash with the salt and keeps the stem", () => {
86
+ const h = (s: string) => Bun.hash(s).toString(16).padStart(16, "0");
87
+ const a = rehashName("client-entry-app__page-abc123.js", "es2019", h);
88
+ const b = rehashName("client-entry-app__page-abc123.js", "es2020", h);
89
+ expect(a).toMatch(/^client-entry-app__page-[0-9a-f]{10}\.js$/);
90
+ expect(a).not.toBe(b);
91
+ expect(rehashName("loro_wasm_bg.wasm", "x", h)).toBe("loro_wasm_bg.wasm");
92
+ });
93
+
94
+ test("browsersBelowFloor lists browsers that cannot load ES module entries", () => {
95
+ expect(browsersBelowFloor(["chrome 90", "safari 10.1", "ie 11", "ios_saf 15.2-15.3"])).toEqual([
96
+ "safari 10.1",
97
+ "ie 11",
98
+ ]);
99
+ });
100
+
101
+ describe("applyClientCompat (real SWC + core-js)", () => {
102
+ function outdirWith(files: Record<string, string>): string {
103
+ // Inside the package so the tools resolve from its node_modules.
104
+ tmp = fs.mkdtempSync(path.join(PKG_DIR, ".compat-test-"));
105
+ const out = path.join(tmp, "client-build");
106
+ for (const [rel, code] of Object.entries(files)) {
107
+ fs.mkdirSync(path.dirname(path.join(out, rel)), { recursive: true });
108
+ fs.writeFileSync(path.join(out, rel), code);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ test("lowers syntax, bundles usage polyfills, and renames every output", async () => {
114
+ const out = outdirWith({
115
+ "client-entry-app__page-aaaa1111.js":
116
+ 'import{x as t}from"./chunks/shared-bbbb2222.js";export const v=t?.y??[1].at(-1);',
117
+ "chunks/shared-bbbb2222.js": "export const x={y:Object.hasOwn({},'a')};",
118
+ });
119
+ const res = await applyClientCompat({
120
+ fs,
121
+ path,
122
+ cwd: tmp!,
123
+ outdir: out,
124
+ jsFiles: ["client-entry-app__page-aaaa1111.js", "chunks/shared-bbbb2222.js"],
125
+ compat: {
126
+ jsTargets: ["chrome 70", "safari 12"],
127
+ cssTargets: [],
128
+ polyfill: "usage",
129
+ sourcemap: false,
130
+ },
131
+ });
132
+
133
+ const entryRel = res.renamed.get("client-entry-app__page-aaaa1111.js")!;
134
+ const chunkRel = res.renamed.get("chunks/shared-bbbb2222.js")!;
135
+ expect(entryRel).toMatch(/^client-entry-app__page-[0-9a-f]{10}\.js$/);
136
+ expect(chunkRel).toMatch(/^chunks\/shared-[0-9a-f]{10}\.js$/);
137
+ expect(fs.existsSync(path.join(out, "client-entry-app__page-aaaa1111.js"))).toBe(false);
138
+
139
+ const entry = fs.readFileSync(path.join(out, entryRel), "utf8");
140
+ expect(entry).not.toContain("?.");
141
+ expect(entry).not.toContain("??");
142
+ expect(entry).not.toContain("core-js");
143
+ // The reference to the renamed chunk was rewritten.
144
+ expect(entry).toContain(path.basename(chunkRel));
145
+ expect(entry).not.toContain("shared-bbbb2222.js");
146
+
147
+ expect(res.polyfills).toMatch(/^polyfills-[0-9a-f]{10}\.js$/);
148
+ const poly = fs.readFileSync(path.join(out, res.polyfills!), "utf8");
149
+ expect(poly.length).toBeGreaterThan(1000);
150
+ expect(poly).not.toMatch(/^import/m);
151
+ expect(res.unsupported).toEqual([]);
152
+ });
153
+
154
+ test("targets that need no polyfill produce no polyfill file", async () => {
155
+ const out = outdirWith({ "client-entry-app__page-cccc3333.js": "export const v=1+1;" });
156
+ const res = await applyClientCompat({
157
+ fs,
158
+ path,
159
+ cwd: tmp!,
160
+ outdir: out,
161
+ jsFiles: ["client-entry-app__page-cccc3333.js"],
162
+ compat: { jsTargets: ["chrome 120"], cssTargets: [], polyfill: "usage", sourcemap: false },
163
+ });
164
+ expect(res.polyfills).toBeUndefined();
165
+ });
166
+ });
167
+
168
+ test("transformCss adds prefixes and lowers nesting for the targets", async () => {
169
+ const css = ".a{user-select:none;&:hover{color:red}}";
170
+ const out = await transformCss(PKG_DIR, css, "x.css", ["safari 13"]);
171
+ expect(out).toContain("-webkit-user-select:none");
172
+ expect(out).toContain(".a:hover");
173
+ });