Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/app/repo/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,50 @@ export interface Resource {
updatetime?: number;
}

export interface ResourceListItem {
key: string;
url: string;
type: ResourceType;
contentType: string;
byteSize: number;
}

export interface ResourceListPage {
items: ResourceListItem[];
offset: number;
limit: number;
total: number;
nextOffset?: number;
}

export interface ResourceChunkRequest {
uuid: string;
url: string;
offset: number;
length: number;
}

export interface ResourceChunk {
url: string;
offset: number;
length: number;
total: number;
/** Base64-encoded bytes without a data-URI prefix. */
base64: string;
}

export const RESOURCE_LIST_PAGE_SIZE = 100;
export const RESOURCE_CHUNK_BYTES = 16 * 1024 * 1024;

export function getResourceByteSize(resource: { content: string; base64?: string }): number {
if (resource.base64) {
const comma = resource.base64.indexOf(",");
const encoded = comma === -1 ? resource.base64 : resource.base64.slice(comma + 1);
return atob(encoded).length;
}
return new TextEncoder().encode(resource.content).byteLength;
}

export interface ResourceHash {
md5: string;
sha1: string;
Expand Down
15 changes: 12 additions & 3 deletions src/app/service/service_worker/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Script, ScriptCode, ScriptRunResource, TClientPageLoadInfo } from "@App/app/repo/scripts";
import { type Resource } from "@App/app/repo/resource";
import {
RESOURCE_LIST_PAGE_SIZE,
type ResourceChunk,
type ResourceChunkRequest,
type ResourceListPage,
} from "@App/app/repo/resource";
import { type Subscribe } from "@App/app/repo/subscribe";
import { type Logger } from "@App/app/repo/logger";
import { type Permission } from "@App/app/repo/permission";
Expand Down Expand Up @@ -298,8 +303,12 @@ export class ResourceClient extends Client {
super(msgSender, "serviceWorker/resource");
}

getScriptResources(script: Script): Promise<{ [key: string]: Resource }> {
return this.doThrow("getScriptResources", script);
getScriptResources(script: Script, offset = 0, limit = RESOURCE_LIST_PAGE_SIZE): Promise<ResourceListPage> {
return this.doThrow("getScriptResources", { script, offset, limit });
}

getResourceChunk(params: ResourceChunkRequest): Promise<ResourceChunk> {
return this.doThrow("getResourceChunk", params);
}

deleteResource(url: string) {
Expand Down
129 changes: 128 additions & 1 deletion src/app/service/service_worker/resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { IMessageQueue } from "@Packages/message/message_queue";
import { parseUrlSRI } from "./utils";
import type { Script } from "@App/app/repo/scripts";
import { SCRIPT_RUN_STATUS_COMPLETE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts";
import type { Resource } from "@App/app/repo/resource";
import { RESOURCE_CHUNK_BYTES, RESOURCE_LIST_PAGE_SIZE, type Resource } from "@App/app/repo/resource";

initTestEnv();

Expand Down Expand Up @@ -412,6 +412,133 @@ describe("ResourceService - getResourceByTypes", () => {
});
});

// 生产环境的 base64 是 blobToBase64 产出的 data URI,resourceModel 的 btoa(content) 不是这种形式
function dataUriResource(): Resource {
return {
...resourceModel("https://example.com/logo.png", ""),
contentType: "image/png",
base64: "data:image/png;base64,iVBORw0KGgr/AQ==",
};
}

describe("ResourceService - resource list and chunks", () => {
let service: ResourceService;

beforeEach(() => {
vi.clearAllMocks();
service = new ResourceService({} as Group, {} as IMessageQueue);
});

it("returns paged resource metadata without transferring content or base64", async () => {
const resource = { ...resourceModel("https://example.com/data.txt", "text"), content: "你好", base64: "" };
vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({
alias: resource,
});

const page = await service.getScriptResourcePage(normalScript("script-page", {}), 0, 1);

expect(page).toEqual({
items: [
{
key: "alias",
url: resource.url,
type: resource.type,
contentType: resource.contentType,
byteSize: new TextEncoder().encode(resource.content).byteLength,
},
],
offset: 0,
limit: 1,
total: 1,
nextOffset: undefined,
});
expect(page.items[0]).not.toHaveProperty("content");
expect(page.items[0]).not.toHaveProperty("base64");
});

it("returns a bounded UTF-8 byte range as raw base64", async () => {
const resource = { ...resourceModel("https://example.com/data.txt", "text"), content: "你好abc", base64: "" };
vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource);

const chunk = await service.getResourceChunk({
uuid: "old-script",
url: resource.url,
offset: 1,
length: 4,
});

expect(chunk).toMatchObject({ url: resource.url, offset: 1, length: 4, total: 9 });
expect([...Uint8Array.from(atob(chunk.base64), (char) => char.charCodeAt(0))]).toEqual([0xbd, 0xa0, 0xe5, 0xa5]);
});

it("measures byteSize by decoded bytes, not by the encoded data-URI string", async () => {
vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({ logo: dataUriResource() });

const page = await service.getScriptResourcePage(normalScript("script-binary", {}), 0, 1);

expect(page.items[0]).toMatchObject({ key: "logo", contentType: "image/png", byteSize: 10 });
});

it("slices a data-URI base64 resource by decoded byte offsets", async () => {
const resource = dataUriResource();
vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource);

const chunk = await service.getResourceChunk({ uuid: "old-script", url: resource.url, offset: 2, length: 4 });

expect(chunk).toMatchObject({ offset: 2, length: 4, total: 10, base64: "TkcNCg==" });
});

it("keeps advancing nextOffset until the last page", async () => {
const first = { ...resourceModel("https://example.com/a.txt", "a"), base64: "" };
const second = { ...resourceModel("https://example.com/b.txt", "b"), base64: "" };
vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({ a: first, b: second });
const script = normalScript("script-pages", {});

const page0 = await service.getScriptResourcePage(script, 0, 1);
const page1 = await service.getScriptResourcePage(script, page0.nextOffset!, 1);

expect(page0).toMatchObject({ offset: 0, total: 2, nextOffset: 1 });
expect(page0.items.map((item) => item.url)).toEqual([first.url]);
expect(page1).toMatchObject({ offset: 1, total: 2, nextOffset: undefined });
expect(page1.items.map((item) => item.url)).toEqual([second.url]);
});

it("accepts the exact page-size limit and rejects a larger one", async () => {
vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({});
const script = normalScript("script-limit", {});

await expect(service.getScriptResourcePage(script, 0, RESOURCE_LIST_PAGE_SIZE)).resolves.toMatchObject({
limit: RESOURCE_LIST_PAGE_SIZE,
});
await expect(service.getScriptResourcePage(script, 0, RESOURCE_LIST_PAGE_SIZE + 1)).rejects.toThrow(
/resource list limit must be between/
);
});

it("accepts the exact chunk-size limit and rejects a larger one", async () => {
const resource = { ...resourceModel("https://example.com/data.txt", "data"), base64: "" };
vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource);
const request = { uuid: "old-script", url: resource.url, offset: 0 };

await expect(service.getResourceChunk({ ...request, length: RESOURCE_CHUNK_BYTES })).resolves.toMatchObject({
length: 4,
total: 4,
});
await expect(service.getResourceChunk({ ...request, length: RESOURCE_CHUNK_BYTES + 1 })).rejects.toThrow(
/resource chunk length must be between/
);
});

it("does not expose chunks to a script that does not own the resource", async () => {
const resource = resourceModel("https://example.com/private.txt", "private");
vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource);

await expect(
service.getResourceChunk({ uuid: "other-script", url: resource.url, offset: 0, length: 1 })
).rejects.toThrow("resource not found");
});
});

describe("ResourceService - updateResourceByTypes", () => {
let service: ResourceService;

Expand Down
67 changes: 64 additions & 3 deletions src/app/service/service_worker/resource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import type { Resource, ResourceHash, ResourceType } from "@App/app/repo/resource";
import {
getResourceByteSize,
RESOURCE_CHUNK_BYTES,
RESOURCE_LIST_PAGE_SIZE,
type Resource,
type ResourceChunk,
type ResourceChunkRequest,
type ResourceHash,
type ResourceListPage,
type ResourceType,
} from "@App/app/repo/resource";
import { ResourceDAO } from "@App/app/repo/resource";
import type { Script, ScriptResource, ScriptResourceByType } from "@App/app/repo/scripts";
import { type IMessageQueue } from "@Packages/message/message_queue";
Expand Down Expand Up @@ -459,12 +469,63 @@ export class ResourceService {
return await this.resourceDAO.save(res);
}

requestGetScriptResources(script: Script): Promise<ScriptResource> {
return this.getScriptResourceValue(script);
async getScriptResourcePage(script: Script, offset = 0, limit = RESOURCE_LIST_PAGE_SIZE): Promise<ResourceListPage> {
if (!Number.isSafeInteger(offset) || offset < 0) {
throw new Error("resource list offset must be a non-negative integer");
}
if (!Number.isSafeInteger(limit) || limit < 1 || limit > RESOURCE_LIST_PAGE_SIZE) {
throw new Error(`resource list limit must be between 1 and ${RESOURCE_LIST_PAGE_SIZE}`);
}

const resources = await this.getScriptResourceValue(script);
const entries = Object.entries(resources);
const items = entries.slice(offset, offset + limit).map(([key, resource]) => ({
key,
url: resource.url,
type: resource.type,
contentType: resource.contentType,
byteSize: getResourceByteSize(resource),
}));
const nextOffset = offset + items.length < entries.length ? offset + items.length : undefined;
return { items, offset, limit, total: entries.length, nextOffset };
}

async getResourceChunk(params: ResourceChunkRequest): Promise<ResourceChunk> {
const { uuid, url, offset, length } = params;
if (!Number.isSafeInteger(offset) || offset < 0) {
throw new Error("resource chunk offset must be a non-negative integer");
}
if (!Number.isSafeInteger(length) || length < 1 || length > RESOURCE_CHUNK_BYTES) {
throw new Error(`resource chunk length must be between 1 and ${RESOURCE_CHUNK_BYTES}`);
}

const resource = await this.resourceDAO.get(url);
if (!resource || !resource.link[uuid]) {
throw new Error("resource not found");
}
const source = resource.base64
? base64ToBlob(resource.base64)
: new Blob([resource.content], { type: resource.contentType });
const total = source.size;
const chunk = source.slice(offset, Math.min(offset + length, total), resource.contentType);
const dataUri = await blobToBase64(chunk);
const comma = dataUri.indexOf(",");
return {
url,
offset,
length: chunk.size,
total,
base64: comma === -1 ? dataUri : dataUri.slice(comma + 1),
};
}

requestGetScriptResources(params: { script: Script; offset?: number; limit?: number }): Promise<ResourceListPage> {
return this.getScriptResourcePage(params.script, params.offset, params.limit);
}

init() {
this.group.on("getScriptResources", this.requestGetScriptResources.bind(this));
this.group.on("getResourceChunk", this.getResourceChunk.bind(this));
this.group.on("deleteResource", this.deleteResource.bind(this));

// 删除相关资源
Expand Down
Loading
Loading