Skip to content
Draft
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
115 changes: 115 additions & 0 deletions src/sources/batch-tiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Client helpers for the batched-tiles fast path (server: maps-api sc-556575).
//
// deck.gl requests a viewport's tiles in one burst; per-tile serving turns that
// into N warehouse queries β€” ruinous on job-based engines. When the source is
// batch-capable the tilejson carries a `tiles_batch` URL; the client coalesces
// the burst into ONE request to it (`&tiles=z/x/y,z/x/y,…`, sorted so a repeated
// viewport is a repeated URL β‡’ CDN-cacheable) and splits the CDTB container the
// server returns, handing each payload to the format's normal tile decoder.
//
// These are the pure pieces (detect / build request / split response); the
// request coalescing itself lives in the deck.gl layer.

import type {TilejsonResult} from './types.js';

export interface BatchTileIndex {
z: number;
x: number;
y: number;
}

/**
* A source is batch-capable when the server advertised a `tiles_batch` endpoint
* in the tilejson (PostgreSQL/BigQuery, binary/geojson). Absent β‡’ the consumer
* must use the per-tile `tiles` template.
*/
export function isBatchTilesTilejson(tilejson: TilejsonResult): boolean {
return Boolean(tilejson.tiles_batch);
}

/**
* Build the URL for a batch of tiles: the advertised `tiles_batch` endpoint with
* `&tiles=z/x/y,…` appended. Tiles are sorted (z, then x, then y) so the same
* viewport always yields the same URL β€” batches stay as CDN-cacheable as single
* tiles. The caller is responsible for keeping the batch within `tiles_batch_max`.
*/
export function buildBatchTilesRequest(
tilejson: TilejsonResult,
tiles: BatchTileIndex[]
): string {
if (!tilejson.tiles_batch) {
throw new Error('tilejson has no batch endpoint (tiles_batch)');
}
Comment on lines +36 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Building a batch request with an empty list of tiles is invalid and would result in an unnecessary network request with an empty tiles= query parameter. Adding a defensive check to throw an error when tiles is empty prevents this scenario.

export function buildBatchTilesRequest(
  tilejson: TilejsonResult,
  tiles: BatchTileIndex[]
): string {
  if (!tilejson.tiles_batch) {
    throw new Error('tilejson has no batch endpoint (tiles_batch)');
  }
  if (tiles.length === 0) {
    throw new Error('Cannot build batch request with zero tiles');
  }

if (tiles.length === 0) {
throw new Error('Cannot build a batch request with zero tiles');
}
const list = [...tiles]
.sort((a, b) => a.z - b.z || a.x - b.x || a.y - b.y)
.map((t) => `${t.z}/${t.x}/${t.y}`)
.join(',');
const separator = tilejson.tiles_batch.includes('?') ? '&' : '?';
return `${tilejson.tiles_batch}${separator}tiles=${list}`;
}

const CDTB_MAGIC = [0x43, 0x44, 0x54, 0x42]; // "CDTB"

/** Key a tile by its `z/x/y` (matches the strings in a batch request). */
export function batchTileKey(tile: BatchTileIndex): string {
return `${tile.z}/${tile.x}/${tile.y}`;
}

/**
* Split the CDTB container the batch endpoint returns into per-tile payloads,
* keyed by `z/x/y`. Each payload is the same bytes a single-tile request would
* have returned, ready for the format's normal decoder.
*
* Container layout (little-endian):
* "CDTB" | u32 tileCount | per tile: u8 z | u32 x | u32 y | u32 len | bytes
*/
export function splitBatchTilesResponse(
data: ArrayBuffer
): Map<string, ArrayBuffer> {
const bytes = new Uint8Array(data);
if (
bytes.length < 8 ||
bytes[0] !== CDTB_MAGIC[0] ||
bytes[1] !== CDTB_MAGIC[1] ||
bytes[2] !== CDTB_MAGIC[2] ||
bytes[3] !== CDTB_MAGIC[3]
) {
throw new Error('Invalid CDTB batch container');
}
const view = new DataView(data);
let offset = 4;
const tileCount = view.getUint32(offset, true);
offset += 4;
const tiles = new Map<string, ArrayBuffer>();
// Each tile header is 13 bytes (u8 z + u32 x + u32 y + u32 len). Bounds-check
// before every read and before slicing the payload so a truncated or
// malformed container throws a clear error instead of a generic DataView
// RangeError or a silently truncated payload.
const TILE_HEADER_BYTES = 13;
for (let i = 0; i < tileCount; i++) {
if (offset + TILE_HEADER_BYTES > data.byteLength) {
throw new Error(
'Malformed CDTB batch container: unexpected end of metadata'
);
}
const z = view.getUint8(offset);
offset += 1;
const x = view.getUint32(offset, true);
offset += 4;
const y = view.getUint32(offset, true);
offset += 4;
const length = view.getUint32(offset, true);
offset += 4;
if (offset + length > data.byteLength) {
throw new Error(
'Malformed CDTB batch container: unexpected end of payload'
);
}
tiles.set(`${z}/${x}/${y}`, data.slice(offset, offset + length));
offset += length;
}
Comment on lines +92 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The binary parser in splitBatchTilesResponse does not perform bounds checks on the buffer before reading the tile headers or slicing the payload. If the input buffer is truncated or malformed, DataView will throw a generic RangeError (for out-of-bounds reads), or data.slice will silently return a truncated buffer, leading to potential data corruption or hard-to-debug errors downstream. Adding explicit bounds checks ensures robust error handling and defensive parsing of binary data.

  for (let i = 0; i < tileCount; i++) {
    if (offset + 13 > data.byteLength) {
      throw new Error('Malformed CDTB batch container: unexpected end of metadata');
    }
    const z = view.getUint8(offset);
    offset += 1;
    const x = view.getUint32(offset, true);
    offset += 4;
    const y = view.getUint32(offset, true);
    offset += 4;
    const length = view.getUint32(offset, true);
    offset += 4;
    if (offset + length > data.byteLength) {
      throw new Error('Malformed CDTB batch container: unexpected end of payload');
    }
    tiles.set(`${z}/${x}/${y}`, data.slice(offset, offset + length));
    offset += length;
  }

return tiles;
}
7 changes: 7 additions & 0 deletions src/sources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import {vectorTilesetSource} from './vector-tileset-source.js';

export {SOURCE_DEFAULTS} from './base-source.js';
export {RasterBandColorinterp} from './constants.js';
export {
isBatchTilesTilejson,
buildBatchTilesRequest,
splitBatchTilesResponse,
batchTileKey,
} from './batch-tiler.js';
export type {BatchTileIndex} from './batch-tiler.js';
export type {
SourceOptions,
SourceRequiredOptions,
Expand Down
12 changes: 12 additions & 0 deletions src/sources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,18 @@ export interface Tilejson {
tilestats: Tilestats;
tileResolution?: TileResolution;

/**
* Batched-tiles fast path. When present, a consumer may coalesce a viewport's
* tiles into one request to this URL by appending `&tiles=z/x/y,z/x/y,…`
* (sorted), up to `tiles_batch_max` tiles. Absent β‡’ use the per-tile `tiles`
* template. Served for PostgreSQL/BigQuery sources in binary/geojson.
*
* @internal
*/
tiles_batch?: string;
/** Max tiles accepted in a single `tiles_batch` request. @internal */
tiles_batch_max?: number;

/**
* Resolution of data in spatial-index dataset (e.g. H3, Quadbin).
*
Expand Down
141 changes: 141 additions & 0 deletions test/sources/batch-tiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {describe, test, expect} from 'vitest';
import {
isBatchTilesTilejson,
buildBatchTilesRequest,
splitBatchTilesResponse,
batchTileKey,
} from '@carto/api-client';
import type {TilejsonResult} from '@carto/api-client';

// Build a CDTB container exactly as the maps-api endpoint does, so the splitter
// is tested against the real wire layout (little-endian):
// "CDTB" | u32 count | per tile: u8 z | u32 x | u32 y | u32 len | bytes
function makeCDTB(
tiles: {z: number; x: number; y: number; payload: number[]}[]
): ArrayBuffer {
let size = 8;
for (const t of tiles) size += 13 + t.payload.length;
const buf = new ArrayBuffer(size);
const view = new DataView(buf);
const bytes = new Uint8Array(buf);
bytes.set([0x43, 0x44, 0x54, 0x42], 0); // "CDTB"
let off = 4;
view.setUint32(off, tiles.length, true);
off += 4;
for (const t of tiles) {
view.setUint8(off, t.z);
off += 1;
view.setUint32(off, t.x, true);
off += 4;
view.setUint32(off, t.y, true);
off += 4;
view.setUint32(off, t.payload.length, true);
off += 4;
bytes.set(t.payload, off);
off += t.payload.length;
}
return buf;
}

const batchTilejson = {
tiles: ['https://x/{z}/{x}/{y}?name=t&formatTiles=binary'],
tiles_batch:
'https://x/table/tiles?name=t&geomType=points&formatTiles=binary',
tiles_batch_max: 32,
} as unknown as TilejsonResult;

describe('batch-tiler', () => {
describe('isBatchTilesTilejson', () => {
test('true when tiles_batch is advertised', () => {
expect(isBatchTilesTilejson(batchTilejson)).toBe(true);
});
test('false when absent', () => {
expect(
isBatchTilesTilejson({
tiles: ['https://x/{z}/{x}/{y}'],
} as unknown as TilejsonResult)
).toBe(false);
});
});

describe('buildBatchTilesRequest', () => {
test('sorts tiles (z,x,y) and appends &tiles=', () => {
const url = buildBatchTilesRequest(batchTilejson, [
{z: 10, x: 5, y: 9},
{z: 8, x: 1, y: 1},
{z: 10, x: 5, y: 1},
{z: 10, x: 2, y: 0},
]);
expect(url).toBe(
'https://x/table/tiles?name=t&geomType=points&formatTiles=binary&tiles=8/1/1,10/2/0,10/5/1,10/5/9'
);
});
test('a repeated viewport (any order) yields the same URL', () => {
const a = buildBatchTilesRequest(batchTilejson, [
{z: 1, x: 0, y: 0},
{z: 1, x: 1, y: 0},
]);
const b = buildBatchTilesRequest(batchTilejson, [
{z: 1, x: 1, y: 0},
{z: 1, x: 0, y: 0},
]);
expect(a).toEqual(b);
});
test('throws when the source is not batch-capable', () => {
expect(() =>
buildBatchTilesRequest({tiles: []} as unknown as TilejsonResult, [
{z: 0, x: 0, y: 0},
])
).toThrow(/tiles_batch/);
});
test('throws when given zero tiles', () => {
expect(() => buildBatchTilesRequest(batchTilejson, [])).toThrow(
/zero tiles/
);
});
});

describe('splitBatchTilesResponse', () => {
test('splits the CDTB container into per-tile payloads keyed by z/x/y', () => {
const buffer = makeCDTB([
{z: 0, x: 0, y: 0, payload: [1, 2, 3]},
{z: 10, x: 523, y: 350, payload: [9, 8, 7, 6]},
]);
const tiles = splitBatchTilesResponse(buffer);
expect([...tiles.keys()]).toEqual(['0/0/0', '10/523/350']);
expect([...new Uint8Array(tiles.get('0/0/0'))]).toEqual([1, 2, 3]);
expect([
...new Uint8Array(tiles.get(batchTileKey({z: 10, x: 523, y: 350}))),
]).toEqual([9, 8, 7, 6]);
});
test('handles an empty (zero-payload) tile', () => {
const tiles = splitBatchTilesResponse(
makeCDTB([{z: 5, x: 1, y: 1, payload: []}])
);
expect(tiles.get('5/1/1').byteLength).toEqual(0);
});
test('throws on a non-CDTB buffer', () => {
const bad = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0, 0, 0, 0]).buffer;
expect(() => splitBatchTilesResponse(bad)).toThrow(/CDTB/);
});
Comment on lines +117 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure the new bounds checks in splitBatchTilesResponse are working correctly and prevent regressions, we should add unit tests that verify the parser throws appropriate errors when encountering truncated headers or payloads.

    test('throws on a non-CDTB buffer', () => {
      const bad = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0, 0, 0, 0]).buffer;
      expect(() => splitBatchTilesResponse(bad)).toThrow(/CDTB/);
    });
    test('throws on truncated header or payload', () => {
      const truncatedHeader = makeCDTB([
        {z: 0, x: 0, y: 0, payload: [1, 2, 3]},
      ]).slice(0, 15);
      expect(() => splitBatchTilesResponse(truncatedHeader)).toThrow(/unexpected end of metadata/);

      const truncatedPayload = makeCDTB([
        {z: 0, x: 0, y: 0, payload: [1, 2, 3]},
      ]).slice(0, 20);
      expect(() => splitBatchTilesResponse(truncatedPayload)).toThrow(/unexpected end of payload/);
    });

test('throws on a truncated tile header', () => {
// Valid container, then cut mid-header (count says 1 tile, header is 13B
// starting at offset 8 β€” slice to 15 leaves only 7 of the 13).
const truncated = makeCDTB([
{z: 0, x: 0, y: 0, payload: [1, 2, 3]},
]).slice(0, 15);
expect(() => splitBatchTilesResponse(truncated)).toThrow(
/unexpected end of metadata/
);
});
test('throws on a truncated payload', () => {
// Full header (through offset 21) but the 3-byte payload is cut off.
const truncated = makeCDTB([
{z: 0, x: 0, y: 0, payload: [1, 2, 3]},
]).slice(0, 22);
expect(() => splitBatchTilesResponse(truncated)).toThrow(
/unexpected end of payload/
);
});
});
});
Loading