@aphrody/shenron-dbxv2 0.0.0-stage → 1.4.7
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/LICENSE +201 -0
- package/README.md +20 -2
- package/package.json +77 -4
- package/src/cache.ts +84 -0
- package/src/client.ts +623 -0
- package/src/db.ts +67 -0
- package/src/declarations.ts +135 -0
- package/src/env.ts +111 -0
- package/src/errors.ts +141 -0
- package/src/guard.ts +115 -0
- package/src/index.ts +15 -0
- package/src/pixel-perfect.ts +139 -0
- package/src/plugin.ts +204 -0
- package/src/preload.ts +13 -0
- package/src/proxy.ts +243 -0
- package/src/refs.ts +126 -0
- package/src/runtime.ts +130 -0
- package/src/schemas.ts +60 -0
- package/src/texture.ts +83 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation of `dbxv2.d.ts`: ambient modules typed per format for `dbxv2:*?<format>` imports and
|
|
3
|
+
* a union of the known entry paths, read from the private index. The file is written under `var/`
|
|
4
|
+
* (ignored by Git) and is itself refused by the public build guard.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, resolve, sep } from "node:path";
|
|
8
|
+
import type { Dbxv2Client } from "./client";
|
|
9
|
+
import { Dbxv2ConfigError } from "./errors";
|
|
10
|
+
import { DBXV2_DECLARATIONS_FILE, DBXV2_MARKER } from "./guard";
|
|
11
|
+
import { formatRef, parseRef, type Dbxv2Ref } from "./refs";
|
|
12
|
+
|
|
13
|
+
export interface DeclarationOptions {
|
|
14
|
+
/** Past this many paths the union degrades to template literal types (default 20 000). */
|
|
15
|
+
readonly maxLiteralPaths?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const EXTENSIONS = {
|
|
19
|
+
png: ["emb"],
|
|
20
|
+
wav: ["acb", "awb"],
|
|
21
|
+
video: ["usm"],
|
|
22
|
+
model: ["emd"],
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
const lit = (value: string): string => JSON.stringify(value);
|
|
26
|
+
|
|
27
|
+
const unionOf = (items: readonly string[], fallback: string): string =>
|
|
28
|
+
items.length === 0 ? fallback : items.map(lit).join("\n | ");
|
|
29
|
+
|
|
30
|
+
/** Text of the declaration file for the given references (already validated). */
|
|
31
|
+
export function renderDeclarations(
|
|
32
|
+
refs: readonly Dbxv2Ref[],
|
|
33
|
+
options: DeclarationOptions = {},
|
|
34
|
+
): string {
|
|
35
|
+
const max = options.maxLiteralPaths ?? 20_000;
|
|
36
|
+
const all = [...new Set(refs.map(formatRef))].toSorted();
|
|
37
|
+
const literal = all.length <= max;
|
|
38
|
+
const byExt = (exts: readonly string[]): string[] =>
|
|
39
|
+
all.filter((p) => exts.includes(p.slice(p.lastIndexOf(".") + 1).toLowerCase()));
|
|
40
|
+
const pathType = (items: readonly string[]): string =>
|
|
41
|
+
literal ? unionOf(items, "never") : "`${string}/${string}`";
|
|
42
|
+
|
|
43
|
+
const lines = [
|
|
44
|
+
`// ${DBXV2_MARKER}: generated from the private DBXV2 index. Never commit, never ship.`,
|
|
45
|
+
"// Regenerate with: bun run --filter @aphrody/shenron-dbxv2 types",
|
|
46
|
+
"",
|
|
47
|
+
"/** Reference to an entry of a CPK: `<cpk>/<path>`. */",
|
|
48
|
+
`type Dbxv2KnownPath =\n | ${pathType(all)};`,
|
|
49
|
+
`type Dbxv2KnownImage =\n | ${pathType(byExt(EXTENSIONS.png))};`,
|
|
50
|
+
`type Dbxv2KnownAudio =\n | ${pathType(byExt(EXTENSIONS.wav))};`,
|
|
51
|
+
`type Dbxv2KnownVideo =\n | ${pathType(byExt(EXTENSIONS.video))};`,
|
|
52
|
+
`type Dbxv2KnownModel =\n | ${pathType(byExt(EXTENSIONS.model))};`,
|
|
53
|
+
'type Dbxv2Specifier = `dbxv2:${Dbxv2KnownPath}?${"png" | "wav" | "video" | "model" | "glb" | "raw"}`;',
|
|
54
|
+
"",
|
|
55
|
+
];
|
|
56
|
+
const resourceModule = (format: string, note: string): string[] => [
|
|
57
|
+
`/** ${note} */`,
|
|
58
|
+
`declare module "dbxv2:*?${format}" {`,
|
|
59
|
+
" /** Entry reference. */",
|
|
60
|
+
" export const ref: { readonly cpk: string; readonly path: string };",
|
|
61
|
+
" /** Short-lived signed loopback URL (no token in it). */",
|
|
62
|
+
" export const url: string;",
|
|
63
|
+
" export function blob(signal?: AbortSignal): Promise<Blob>;",
|
|
64
|
+
" export function arrayBuffer(signal?: AbortSignal): Promise<ArrayBuffer>;",
|
|
65
|
+
" export default url;",
|
|
66
|
+
"}",
|
|
67
|
+
"",
|
|
68
|
+
];
|
|
69
|
+
lines.push(
|
|
70
|
+
...resourceModule("png", "EMB texture as PNG (`?png&index=N` selects another texture)."),
|
|
71
|
+
...resourceModule("wav", "ACB sub-sound as WAV (`?wav&cue=N`)."),
|
|
72
|
+
...resourceModule("video", "USM as MP4, WebM or MPEG-2."),
|
|
73
|
+
...resourceModule("glb", "Assembled EMD as binary glTF."),
|
|
74
|
+
"/** Assembled EMD metadata (geometry and texture sizes), validated at load. */",
|
|
75
|
+
'declare module "dbxv2:*?model" {',
|
|
76
|
+
' import type { Dbxv2ModelJson } from "@aphrody/shenron-dbxv2/schemas";',
|
|
77
|
+
" export const ref: { readonly cpk: string; readonly path: string };",
|
|
78
|
+
" export const jsonUrl: string;",
|
|
79
|
+
" export const glbUrl: string;",
|
|
80
|
+
" const model: Dbxv2ModelJson;",
|
|
81
|
+
" export default model;",
|
|
82
|
+
"}",
|
|
83
|
+
"",
|
|
84
|
+
"/** Decompressed entry bytes. */",
|
|
85
|
+
'declare module "dbxv2:*?raw" {',
|
|
86
|
+
" export const ref: { readonly cpk: string; readonly path: string };",
|
|
87
|
+
" export const url: string;",
|
|
88
|
+
" export function blob(signal?: AbortSignal): Promise<Blob>;",
|
|
89
|
+
" const bytes: ArrayBuffer;",
|
|
90
|
+
" export default bytes;",
|
|
91
|
+
"}",
|
|
92
|
+
"",
|
|
93
|
+
);
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every reference of the private server (all CPKs, or those listed), through `find`. */
|
|
98
|
+
export async function collectKnownRefs(
|
|
99
|
+
client: Dbxv2Client,
|
|
100
|
+
options: { readonly cpks?: readonly string[]; readonly signal?: AbortSignal } = {},
|
|
101
|
+
): Promise<Dbxv2Ref[]> {
|
|
102
|
+
const names = options.cpks ?? (await client.cpks({ signal: options.signal })).map((c) => c.name);
|
|
103
|
+
const refs: Dbxv2Ref[] = [];
|
|
104
|
+
for (const name of names) {
|
|
105
|
+
for await (const entry of client.findAll(name, { signal: options.signal, pageSize: 5000 })) {
|
|
106
|
+
try {
|
|
107
|
+
refs.push(parseRef({ cpk: name, path: entry.path }));
|
|
108
|
+
} catch {
|
|
109
|
+
// an entry name the reference grammar rejects cannot be imported anyway
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return refs;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Default target: `<cwd>/var/dbxv2/dbxv2.d.ts` (ignored by Git). */
|
|
117
|
+
export function defaultDeclarationsPath(cwd: string): string {
|
|
118
|
+
return resolve(cwd, "var", "dbxv2", DBXV2_DECLARATIONS_FILE);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Writes the declarations; refuses a path outside a `var/` directory unless `allowOutsideVar`. */
|
|
122
|
+
export function writeDeclarations(
|
|
123
|
+
outFile: string,
|
|
124
|
+
content: string,
|
|
125
|
+
options: { readonly allowOutsideVar?: boolean } = {},
|
|
126
|
+
): string {
|
|
127
|
+
const target = resolve(outFile);
|
|
128
|
+
if (!options.allowOutsideVar && !target.split(sep).includes("var"))
|
|
129
|
+
throw new Dbxv2ConfigError(
|
|
130
|
+
`refusing to write DBXV2 declarations outside a var/ directory: ${target}`,
|
|
131
|
+
);
|
|
132
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
133
|
+
writeFileSync(target, content, "utf8");
|
|
134
|
+
return target;
|
|
135
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit public gateway configuration and private server gating.
|
|
3
|
+
* Local database paths remain unavailable in public builds. Pure: callers pass the environment.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type Dbxv2EnvInput = Readonly<Record<string, string | undefined>>;
|
|
7
|
+
|
|
8
|
+
export const DBXV2_MIN_TOKEN_LENGTH = 16;
|
|
9
|
+
|
|
10
|
+
export interface Dbxv2Config {
|
|
11
|
+
/** Origin (and optional path prefix) of the server, without trailing slash. */
|
|
12
|
+
readonly baseUrl: string;
|
|
13
|
+
readonly token?: string;
|
|
14
|
+
readonly mode?: "public";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Dbxv2DisabledReason =
|
|
18
|
+
| "public-render"
|
|
19
|
+
| "production"
|
|
20
|
+
| "missing-url"
|
|
21
|
+
| "invalid-url"
|
|
22
|
+
| "missing-token"
|
|
23
|
+
| "short-token";
|
|
24
|
+
|
|
25
|
+
export type Dbxv2Status =
|
|
26
|
+
| { readonly enabled: true; readonly config: Dbxv2Config }
|
|
27
|
+
| { readonly enabled: false; readonly reason: Dbxv2DisabledReason };
|
|
28
|
+
|
|
29
|
+
const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]", ""]);
|
|
30
|
+
|
|
31
|
+
const truthy = (value: string | undefined): boolean =>
|
|
32
|
+
value !== undefined && /^(?:1|true|yes|on)$/i.test(value.trim());
|
|
33
|
+
|
|
34
|
+
/** A public render or build (or a production runtime): the private features never run there. */
|
|
35
|
+
export function isPublicContext(env: Dbxv2EnvInput): Dbxv2DisabledReason | null {
|
|
36
|
+
if (
|
|
37
|
+
env.SHENRON_PROFILE === "public" ||
|
|
38
|
+
truthy(env.SHENRON_PUBLIC_RENDER) ||
|
|
39
|
+
truthy(env.SHENRON_PUBLIC_BUILD)
|
|
40
|
+
)
|
|
41
|
+
return "public-render";
|
|
42
|
+
if (env.NODE_ENV === "production") return "production";
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parses and validates a server URL; null when unusable (not http(s), wildcard host, credentials). */
|
|
47
|
+
export function normalizeServerUrl(raw: string): string | null {
|
|
48
|
+
let url: URL;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(raw.trim());
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
55
|
+
if (WILDCARD_HOSTS.has(url.hostname)) return null;
|
|
56
|
+
if (url.username || url.password || url.search || url.hash) return null;
|
|
57
|
+
return url.href.replace(/\/+$/, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function dbxv2Status(env: Dbxv2EnvInput): Dbxv2Status {
|
|
61
|
+
if (env.DBXV2_CLIENT_MODE === "public") {
|
|
62
|
+
const baseUrl = normalizeServerUrl(env.DBXV2_PUBLIC_URL ?? "");
|
|
63
|
+
if (!baseUrl || !baseUrl.startsWith("https://"))
|
|
64
|
+
return { enabled: false, reason: "invalid-url" };
|
|
65
|
+
return { enabled: true, config: { mode: "public", baseUrl } };
|
|
66
|
+
}
|
|
67
|
+
const context = isPublicContext(env);
|
|
68
|
+
if (context === "public-render") return { enabled: false, reason: context };
|
|
69
|
+
const rawUrl = env.DBXV2_SERVER_URL?.trim();
|
|
70
|
+
if (!rawUrl) return { enabled: false, reason: "missing-url" };
|
|
71
|
+
const baseUrl = normalizeServerUrl(rawUrl);
|
|
72
|
+
if (!baseUrl) return { enabled: false, reason: "invalid-url" };
|
|
73
|
+
const token = env.DBXV2_TOKEN ?? "";
|
|
74
|
+
if (!token) return { enabled: false, reason: "missing-token" };
|
|
75
|
+
if (token.length < DBXV2_MIN_TOKEN_LENGTH) return { enabled: false, reason: "short-token" };
|
|
76
|
+
return { enabled: true, config: { baseUrl, token } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The server client features (`DBXV2_SERVER_URL` and `DBXV2_TOKEN`) are usable. */
|
|
80
|
+
export function isDbxv2Enabled(env: Dbxv2EnvInput): boolean {
|
|
81
|
+
return dbxv2Status(env).enabled;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Path of the private database (`DBXV2_DB_PATH`), or null when unset or in a public context. */
|
|
85
|
+
export function dbxv2DbPath(env: Dbxv2EnvInput): string | null {
|
|
86
|
+
if (isPublicContext(env)) return null;
|
|
87
|
+
const path = env.DBXV2_DB_PATH?.trim();
|
|
88
|
+
return path ? path : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isDbxv2DbEnabled(env: Dbxv2EnvInput): boolean {
|
|
92
|
+
return dbxv2DbPath(env) !== null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const REASONS: Readonly<Record<Dbxv2DisabledReason, string>> = {
|
|
96
|
+
"public-render": "DBXV2 is never available in a public render or build",
|
|
97
|
+
production: "DBXV2 is disabled when NODE_ENV=production",
|
|
98
|
+
"missing-url": "DBXV2_SERVER_URL is not set",
|
|
99
|
+
"invalid-url": "DBXV2_SERVER_URL must be an http(s) URL without credentials or wildcard host",
|
|
100
|
+
"missing-token": "DBXV2_TOKEN is not set",
|
|
101
|
+
"short-token": `DBXV2_TOKEN must hold at least ${DBXV2_MIN_TOKEN_LENGTH} characters`,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function describeDisabledReason(reason: Dbxv2DisabledReason): string {
|
|
105
|
+
return REASONS[reason];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The process environment unless one is given (the only place that reads `process.env` by default). */
|
|
109
|
+
export function currentEnv(env: Dbxv2EnvInput = process.env): Dbxv2EnvInput {
|
|
110
|
+
return env;
|
|
111
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Typed errors and a Result helper. Messages never carry the token or any query string. */
|
|
2
|
+
|
|
3
|
+
export type Dbxv2ErrorKind =
|
|
4
|
+
| "disabled"
|
|
5
|
+
| "config"
|
|
6
|
+
| "auth"
|
|
7
|
+
| "forbidden"
|
|
8
|
+
| "not_found"
|
|
9
|
+
| "range"
|
|
10
|
+
| "busy"
|
|
11
|
+
| "timeout"
|
|
12
|
+
| "server"
|
|
13
|
+
| "network"
|
|
14
|
+
| "aborted"
|
|
15
|
+
| "schema"
|
|
16
|
+
| "changed"
|
|
17
|
+
| "build_refused";
|
|
18
|
+
|
|
19
|
+
/** Bytes received before a download failed, to resume with `download({ resume })`. */
|
|
20
|
+
export interface Dbxv2Partial {
|
|
21
|
+
readonly bytes: Uint8Array;
|
|
22
|
+
readonly etag: string;
|
|
23
|
+
readonly size: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class Dbxv2Error extends Error {
|
|
27
|
+
readonly kind: Dbxv2ErrorKind;
|
|
28
|
+
readonly status: number | null;
|
|
29
|
+
partial?: Dbxv2Partial;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
kind: Dbxv2ErrorKind,
|
|
33
|
+
message: string,
|
|
34
|
+
options: { status?: number | null; cause?: unknown } = {},
|
|
35
|
+
) {
|
|
36
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
37
|
+
this.name = new.target.name;
|
|
38
|
+
this.kind = kind;
|
|
39
|
+
this.status = options.status ?? null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class Dbxv2DisabledError extends Dbxv2Error {
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super("disabled", message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class Dbxv2ConfigError extends Dbxv2Error {
|
|
49
|
+
constructor(message: string) {
|
|
50
|
+
super("config", message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class Dbxv2AuthError extends Dbxv2Error {
|
|
54
|
+
constructor(message = "unauthorized: check DBXV2_TOKEN") {
|
|
55
|
+
super("auth", message, { status: 401 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export class Dbxv2ForbiddenError extends Dbxv2Error {
|
|
59
|
+
constructor(message: string) {
|
|
60
|
+
super("forbidden", message, { status: 403 });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export class Dbxv2NotFoundError extends Dbxv2Error {
|
|
64
|
+
constructor(message: string) {
|
|
65
|
+
super("not_found", message, { status: 404 });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class Dbxv2RangeError extends Dbxv2Error {
|
|
69
|
+
constructor(message: string) {
|
|
70
|
+
super("range", message, { status: 416 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export class Dbxv2BusyError extends Dbxv2Error {
|
|
74
|
+
constructor(message: string, status = 503) {
|
|
75
|
+
super("busy", message, { status });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class Dbxv2TimeoutError extends Dbxv2Error {
|
|
79
|
+
constructor(message: string, status: number | null = null) {
|
|
80
|
+
super("timeout", message, { status });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class Dbxv2ServerError extends Dbxv2Error {
|
|
84
|
+
constructor(message: string, status: number) {
|
|
85
|
+
super("server", message, { status });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export class Dbxv2NetworkError extends Dbxv2Error {
|
|
89
|
+
constructor(message: string, cause?: unknown) {
|
|
90
|
+
super("network", message, { cause });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export class Dbxv2AbortError extends Dbxv2Error {
|
|
94
|
+
constructor(message = "request aborted") {
|
|
95
|
+
super("aborted", message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export class Dbxv2SchemaError extends Dbxv2Error {
|
|
99
|
+
readonly issues: readonly string[];
|
|
100
|
+
constructor(message: string, issues: readonly string[] = []) {
|
|
101
|
+
super("schema", message);
|
|
102
|
+
this.issues = issues;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** The entry changed on the server while a ranged download was in progress. */
|
|
106
|
+
export class Dbxv2ChangedError extends Dbxv2Error {
|
|
107
|
+
constructor(message: string) {
|
|
108
|
+
super("changed", message, { status: 200 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export class Dbxv2BuildRefusedError extends Dbxv2Error {
|
|
112
|
+
constructor(message: string) {
|
|
113
|
+
super("build_refused", message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type Result<T, E extends Error = Dbxv2Error> =
|
|
118
|
+
| { readonly ok: true; readonly value: T }
|
|
119
|
+
| { readonly ok: false; readonly error: E };
|
|
120
|
+
|
|
121
|
+
/** Runs `fn` and turns a thrown {@link Dbxv2Error} into a failed Result; other errors propagate. */
|
|
122
|
+
export async function attempt<T>(fn: () => Promise<T>): Promise<Result<T>> {
|
|
123
|
+
try {
|
|
124
|
+
return { ok: true, value: await fn() };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof Dbxv2Error) return { ok: false, error };
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Maps an HTTP error status of the private server to a typed error. */
|
|
132
|
+
export function errorForStatus(status: number, detail: string): Dbxv2Error {
|
|
133
|
+
const message = `${detail} (HTTP ${status})`;
|
|
134
|
+
if (status === 401) return new Dbxv2AuthError(message);
|
|
135
|
+
if (status === 403) return new Dbxv2ForbiddenError(message);
|
|
136
|
+
if (status === 404) return new Dbxv2NotFoundError(message);
|
|
137
|
+
if (status === 416) return new Dbxv2RangeError(message);
|
|
138
|
+
if (status === 503) return new Dbxv2BusyError(message, status);
|
|
139
|
+
if (status === 504) return new Dbxv2TimeoutError(message, status);
|
|
140
|
+
return new Dbxv2ServerError(message, status);
|
|
141
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-build guard: no DBXV2 module, type declaration or marker may reach a public bundle or
|
|
3
|
+
* release tree. Every module generated by the `dbxv2:` plugin carries {@link DBXV2_MARKER}, and
|
|
4
|
+
* the public server build (`scripts/build/server.ts`) fails when it finds one.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { Dbxv2BuildRefusedError } from "./errors";
|
|
9
|
+
import { isPublicContext, type Dbxv2EnvInput } from "./env";
|
|
10
|
+
|
|
11
|
+
/** Literal present in every generated module and declaration file. */
|
|
12
|
+
export const DBXV2_MARKER = "@dbxv2-private";
|
|
13
|
+
|
|
14
|
+
/** Basename of the generated declarations. */
|
|
15
|
+
export const DBXV2_DECLARATIONS_FILE = "dbxv2.d.ts";
|
|
16
|
+
|
|
17
|
+
/** The subset of `Bun.build` options the guard looks at. */
|
|
18
|
+
export interface BuildConfigLike {
|
|
19
|
+
readonly minify?: unknown;
|
|
20
|
+
readonly define?: Readonly<Record<string, string>>;
|
|
21
|
+
readonly sourcemap?: unknown;
|
|
22
|
+
readonly splitting?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Why a build is considered public/production, or null when it is a development build. */
|
|
26
|
+
export function publicBuildReason(
|
|
27
|
+
config: BuildConfigLike | undefined,
|
|
28
|
+
env: Dbxv2EnvInput,
|
|
29
|
+
): string | null {
|
|
30
|
+
const context = isPublicContext(env);
|
|
31
|
+
if (context === "public-render") return "SHENRON_PROFILE/SHENRON_PUBLIC_* marks a public build";
|
|
32
|
+
if (context === "production") return "NODE_ENV=production";
|
|
33
|
+
if (config?.minify) return "minify is enabled (production build)";
|
|
34
|
+
const nodeEnv = config?.define?.["process.env.NODE_ENV"];
|
|
35
|
+
if (nodeEnv !== undefined && /production/.test(nodeEnv)) return "define NODE_ENV=production";
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Throws {@link Dbxv2BuildRefusedError} when a public or production build asks for DBXV2 modules. */
|
|
40
|
+
export function assertDbxv2BuildAllowed(
|
|
41
|
+
config: BuildConfigLike | undefined,
|
|
42
|
+
env: Dbxv2EnvInput,
|
|
43
|
+
): void {
|
|
44
|
+
const reason = publicBuildReason(config, env);
|
|
45
|
+
if (reason) {
|
|
46
|
+
throw new Dbxv2BuildRefusedError(
|
|
47
|
+
`Bun.build refused for dbxv2: modules (${reason}). DBXV2 is private (Bandai Namco / Dimps) ` +
|
|
48
|
+
"and must never be bundled into a public or production artifact.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Binary public assets (WebAssembly, images, audio, video, models) are scanned too: the marker,
|
|
55
|
+
* the private relay/serve names and the private-store references must not appear in them.
|
|
56
|
+
*/
|
|
57
|
+
const BINARY_SCANNED =
|
|
58
|
+
/\.(?:wasm|png|webp|avif|jpe?g|gif|ico|mp3|ogg|wav|mp4|m2v|webm|glb|gltf|bin)$/i;
|
|
59
|
+
const MAX_BINARY = 256 * 1024 * 1024;
|
|
60
|
+
|
|
61
|
+
/** Byte strings that identify the private DBXV2 store or its relays inside a binary asset. */
|
|
62
|
+
export const DBXV2_BINARY_REFERENCES: readonly string[] = [
|
|
63
|
+
DBXV2_MARKER,
|
|
64
|
+
"admin/dbxv2/media",
|
|
65
|
+
"dbxv2-serve",
|
|
66
|
+
"DBXV2_SERVER_URL",
|
|
67
|
+
"DBXV2_DB_PATH",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const SCANNED = /\.(?:m?js|cjs|ts|mts|cts|d\.ts|map|json|html)$/i;
|
|
71
|
+
const MAX_FILE = 16 * 1024 * 1024;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Files under `dir` that carry the DBXV2 marker (text or binary: wasm, images, media) or are the generated declarations (paths relative
|
|
75
|
+
* to `dir`). `node_modules` are not skipped: a leak there ships as well.
|
|
76
|
+
*/
|
|
77
|
+
export function findDbxv2Leaks(dir: string): string[] {
|
|
78
|
+
const leaks: string[] = [];
|
|
79
|
+
const walk = (current: string, prefix: string): void => {
|
|
80
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
81
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
82
|
+
const full = join(current, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
walk(full, rel);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!entry.isFile()) continue;
|
|
88
|
+
if (entry.name === DBXV2_DECLARATIONS_FILE) {
|
|
89
|
+
leaks.push(rel);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (BINARY_SCANNED.test(entry.name)) {
|
|
93
|
+
try {
|
|
94
|
+
if (statSync(full).size > MAX_BINARY) continue;
|
|
95
|
+
const bytes = readFileSync(full);
|
|
96
|
+
if (DBXV2_BINARY_REFERENCES.some((needle) => bytes.includes(needle))) leaks.push(rel);
|
|
97
|
+
} catch {
|
|
98
|
+
// unreadable file: nothing to scan
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!SCANNED.test(entry.name)) continue;
|
|
103
|
+
let size: number;
|
|
104
|
+
try {
|
|
105
|
+
size = statSync(full).size;
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (size > MAX_FILE) continue;
|
|
110
|
+
if (readFileSync(full, "latin1").includes(DBXV2_MARKER)) leaks.push(rel);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(dir, "");
|
|
114
|
+
return leaks;
|
|
115
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Public API of `@aphrody/shenron-dbxv2` (private package: DBXV2 data is never public). */
|
|
2
|
+
export * from "./cache";
|
|
3
|
+
export * from "./client";
|
|
4
|
+
export * from "./db";
|
|
5
|
+
export * from "./declarations";
|
|
6
|
+
export * from "./env";
|
|
7
|
+
export * from "./errors";
|
|
8
|
+
export * from "./guard";
|
|
9
|
+
export * from "./pixel-perfect";
|
|
10
|
+
export * from "./plugin";
|
|
11
|
+
export * from "./proxy";
|
|
12
|
+
export * from "./refs";
|
|
13
|
+
export * from "./runtime";
|
|
14
|
+
export * from "./schemas";
|
|
15
|
+
export * from "./texture";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pixel-perfect presentation helpers for DBXV2 textures (DOM / canvas), and the sampler contract
|
|
3
|
+
* for wgpu / Bevy.
|
|
4
|
+
*
|
|
5
|
+
* Rules: integer scale only (in device pixels), nearest-neighbour sampling, no sub-pixel offset,
|
|
6
|
+
* `devicePixelRatio` taken into account, position snapped to the device pixel grid.
|
|
7
|
+
*
|
|
8
|
+
* wgpu / Bevy (crates/shenron-engine) must apply the equivalent: `FilterMode::Nearest` for mag,
|
|
9
|
+
* min and mipmap, `mip_level_count = 1`, `AddressMode::ClampToEdge`, exact UVs (texel centres, no
|
|
10
|
+
* bias), a viewport / quad whose size is a whole multiple of the texture size in physical pixels
|
|
11
|
+
* and whose origin is a whole physical pixel, and an `Rgba8UnormSrgb` (colour) or `Rgba8Unorm`
|
|
12
|
+
* (data) format matching the declared colour space. {@link NEAREST_SAMPLER} is the wgpu descriptor.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface PixelBox {
|
|
16
|
+
/** Available width / height in CSS pixels. */
|
|
17
|
+
readonly width: number;
|
|
18
|
+
readonly height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PixelPerfectOptions {
|
|
22
|
+
/** Device pixel ratio (default 1). */
|
|
23
|
+
readonly dpr?: number;
|
|
24
|
+
/** Upper bound of the integer scale (default unbounded). */
|
|
25
|
+
readonly maxScale?: number;
|
|
26
|
+
/** Fail instead of showing the image smaller than 1:1 when it does not fit (default: clamp to 1). */
|
|
27
|
+
readonly strict?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PixelPlacement {
|
|
31
|
+
/** Integer scale in device pixels per texel. */
|
|
32
|
+
readonly scale: number;
|
|
33
|
+
/** Size in device pixels (whole numbers). */
|
|
34
|
+
readonly deviceWidth: number;
|
|
35
|
+
readonly deviceHeight: number;
|
|
36
|
+
/** Origin in device pixels, centred in the box and floored to the grid. */
|
|
37
|
+
readonly deviceX: number;
|
|
38
|
+
readonly deviceY: number;
|
|
39
|
+
/** Same values in CSS pixels (device / dpr); exact when dpr is an integer. */
|
|
40
|
+
readonly cssWidth: number;
|
|
41
|
+
readonly cssHeight: number;
|
|
42
|
+
readonly cssX: number;
|
|
43
|
+
readonly cssY: number;
|
|
44
|
+
/** The image does not fit even at scale 1 (only reported when not strict). */
|
|
45
|
+
readonly overflow: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Largest integer scale whose result fits the box, positioned on the device pixel grid. */
|
|
49
|
+
export function pixelPerfect(
|
|
50
|
+
imageWidth: number,
|
|
51
|
+
imageHeight: number,
|
|
52
|
+
box: PixelBox,
|
|
53
|
+
options: PixelPerfectOptions = {},
|
|
54
|
+
): PixelPlacement {
|
|
55
|
+
const dpr = options.dpr && options.dpr > 0 ? options.dpr : 1;
|
|
56
|
+
if (
|
|
57
|
+
!(imageWidth > 0) ||
|
|
58
|
+
!(imageHeight > 0) ||
|
|
59
|
+
!Number.isInteger(imageWidth) ||
|
|
60
|
+
!Number.isInteger(imageHeight)
|
|
61
|
+
)
|
|
62
|
+
throw new RangeError("image dimensions must be positive integers");
|
|
63
|
+
const boxW = Math.floor(box.width * dpr);
|
|
64
|
+
const boxH = Math.floor(box.height * dpr);
|
|
65
|
+
const fit = Math.floor(Math.min(boxW / imageWidth, boxH / imageHeight));
|
|
66
|
+
if (fit < 1 && options.strict) throw new RangeError("image does not fit at an integer scale");
|
|
67
|
+
const scale = Math.max(1, Math.min(fit, options.maxScale ?? Number.POSITIVE_INFINITY));
|
|
68
|
+
const deviceWidth = imageWidth * scale;
|
|
69
|
+
const deviceHeight = imageHeight * scale;
|
|
70
|
+
const deviceX = Math.floor((boxW - deviceWidth) / 2);
|
|
71
|
+
const deviceY = Math.floor((boxH - deviceHeight) / 2);
|
|
72
|
+
return {
|
|
73
|
+
scale,
|
|
74
|
+
deviceWidth,
|
|
75
|
+
deviceHeight,
|
|
76
|
+
deviceX,
|
|
77
|
+
deviceY,
|
|
78
|
+
cssWidth: deviceWidth / dpr,
|
|
79
|
+
cssHeight: deviceHeight / dpr,
|
|
80
|
+
cssX: deviceX / dpr,
|
|
81
|
+
cssY: deviceY / dpr,
|
|
82
|
+
overflow: fit < 1,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Inline style for an `<img>` or `<canvas>` positioned in a relatively-positioned box. */
|
|
87
|
+
export function pixelPerfectStyle(placement: PixelPlacement): Record<string, string> {
|
|
88
|
+
return {
|
|
89
|
+
position: "absolute",
|
|
90
|
+
left: `${placement.cssX}px`,
|
|
91
|
+
top: `${placement.cssY}px`,
|
|
92
|
+
width: `${placement.cssWidth}px`,
|
|
93
|
+
height: `${placement.cssHeight}px`,
|
|
94
|
+
imageRendering: "pixelated",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Minimal canvas 2D surface (structural, no DOM class needed). */
|
|
99
|
+
export interface PixelCanvasContext {
|
|
100
|
+
imageSmoothingEnabled: boolean;
|
|
101
|
+
drawImage(image: unknown, dx: number, dy: number, dw: number, dh: number): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Draws on a canvas whose backing store is already in device pixels (width = css * dpr).
|
|
106
|
+
* Smoothing is disabled and the destination is whole device pixels.
|
|
107
|
+
*/
|
|
108
|
+
export function drawPixelPerfect(
|
|
109
|
+
ctx: PixelCanvasContext,
|
|
110
|
+
image: unknown,
|
|
111
|
+
placement: PixelPlacement,
|
|
112
|
+
): void {
|
|
113
|
+
ctx.imageSmoothingEnabled = false;
|
|
114
|
+
ctx.drawImage(
|
|
115
|
+
image,
|
|
116
|
+
placement.deviceX,
|
|
117
|
+
placement.deviceY,
|
|
118
|
+
placement.deviceWidth,
|
|
119
|
+
placement.deviceHeight,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** wgpu sampler descriptor: nearest everywhere, no mip, clamped, no LOD bias. */
|
|
124
|
+
export const NEAREST_SAMPLER = {
|
|
125
|
+
magFilter: "nearest",
|
|
126
|
+
minFilter: "nearest",
|
|
127
|
+
mipmapFilter: "nearest",
|
|
128
|
+
addressModeU: "clamp-to-edge",
|
|
129
|
+
addressModeV: "clamp-to-edge",
|
|
130
|
+
addressModeW: "clamp-to-edge",
|
|
131
|
+
lodMinClamp: 0,
|
|
132
|
+
lodMaxClamp: 0,
|
|
133
|
+
maxAnisotropy: 1,
|
|
134
|
+
} as const;
|
|
135
|
+
|
|
136
|
+
/** Texture descriptor fragments matching a declared colour space (single mip level). */
|
|
137
|
+
export function wgpuTextureFormat(colorSpace: "srgb" | "linear"): "rgba8unorm-srgb" | "rgba8unorm" {
|
|
138
|
+
return colorSpace === "srgb" ? "rgba8unorm-srgb" : "rgba8unorm";
|
|
139
|
+
}
|